Phases 5, 6, 8: OCI pull/push paths and admin REST API

Phase 5 (OCI pull): internal/oci/ package with manifest GET/HEAD by
tag/digest, blob GET/HEAD with repo membership check, tag listing with
OCI pagination, catalog listing. Multi-segment repo names via
parseOCIPath() right-split routing. DB query layer in
internal/db/repository.go.

Phase 6 (OCI push): blob uploads (monolithic and chunked) with
uploadManager tracking in-progress BlobWriters, manifest push
implementing full ARCHITECTURE.md §5 flow in a single SQLite
transaction (create repo, upsert manifest, populate manifest_blobs,
atomic tag move). Digest verification on both blob commit and manifest
push-by-digest.

Phase 8 (admin REST): /v1 endpoints for auth (login/logout/health),
repository management (list/detail/delete), policy CRUD with engine
reload, audit log listing with filters, GC trigger/status stubs.
RequireAdmin middleware, platform-standard error format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:25:18 -07:00
parent f5e67bd4aa
commit dddc66f31b
40 changed files with 6832 additions and 7 deletions

429
internal/db/admin.go Normal file
View File

@@ -0,0 +1,429 @@
package db
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// ErrPolicyRuleNotFound is returned when a policy rule lookup finds no matching row.
var ErrPolicyRuleNotFound = errors.New("db: policy rule not found")
// RepoMetadata is a repository with aggregate counts for listing.
type RepoMetadata struct {
Name string `json:"name"`
TagCount int `json:"tag_count"`
ManifestCount int `json:"manifest_count"`
TotalSize int64 `json:"total_size"`
CreatedAt string `json:"created_at"`
}
// TagInfo is a tag with its manifest digest for repo detail.
type TagInfo struct {
Name string `json:"name"`
Digest string `json:"digest"`
}
// ManifestInfo is a manifest summary for repo detail.
type ManifestInfo struct {
Digest string `json:"digest"`
MediaType string `json:"media_type"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
}
// RepoDetail contains detailed info about a single repository.
type RepoDetail struct {
Name string `json:"name"`
Tags []TagInfo `json:"tags"`
Manifests []ManifestInfo `json:"manifests"`
TotalSize int64 `json:"total_size"`
CreatedAt string `json:"created_at"`
}
// PolicyRuleRow represents a row from the policy_rules table with parsed JSON.
type PolicyRuleRow struct {
ID int64 `json:"id"`
Priority int `json:"priority"`
Description string `json:"description"`
Effect string `json:"effect"`
Roles []string `json:"roles,omitempty"`
AccountTypes []string `json:"account_types,omitempty"`
SubjectUUID string `json:"subject_uuid,omitempty"`
Actions []string `json:"actions"`
Repositories []string `json:"repositories,omitempty"`
Enabled bool `json:"enabled"`
CreatedBy string `json:"created_by,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ListRepositoriesWithMetadata returns all repositories with tag count,
// manifest count, and total size.
func (d *DB) ListRepositoriesWithMetadata(limit, offset int) ([]RepoMetadata, error) {
if limit <= 0 {
limit = 50
}
rows, err := d.Query(
`SELECT r.name, r.created_at,
(SELECT COUNT(*) FROM tags t WHERE t.repository_id = r.id) AS tag_count,
(SELECT COUNT(*) FROM manifests m WHERE m.repository_id = r.id) AS manifest_count,
COALESCE((SELECT SUM(m.size) FROM manifests m WHERE m.repository_id = r.id), 0) AS total_size
FROM repositories r
ORDER BY r.name ASC
LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("db: list repositories: %w", err)
}
defer func() { _ = rows.Close() }()
var repos []RepoMetadata
for rows.Next() {
var r RepoMetadata
if err := rows.Scan(&r.Name, &r.CreatedAt, &r.TagCount, &r.ManifestCount, &r.TotalSize); err != nil {
return nil, fmt.Errorf("db: scan repository: %w", err)
}
repos = append(repos, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate repositories: %w", err)
}
return repos, nil
}
// GetRepositoryDetail returns detailed information about a repository.
func (d *DB) GetRepositoryDetail(name string) (*RepoDetail, error) {
var repoID int64
var createdAt string
err := d.QueryRow(`SELECT id, created_at FROM repositories WHERE name = ?`, name).Scan(&repoID, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRepoNotFound
}
return nil, fmt.Errorf("db: get repository: %w", err)
}
detail := &RepoDetail{Name: name, CreatedAt: createdAt}
// Tags with manifest digests.
tagRows, err := d.Query(
`SELECT t.name, m.digest
FROM tags t JOIN manifests m ON m.id = t.manifest_id
WHERE t.repository_id = ?
ORDER BY t.name ASC`, repoID,
)
if err != nil {
return nil, fmt.Errorf("db: list repo tags: %w", err)
}
defer func() { _ = tagRows.Close() }()
for tagRows.Next() {
var ti TagInfo
if err := tagRows.Scan(&ti.Name, &ti.Digest); err != nil {
return nil, fmt.Errorf("db: scan tag: %w", err)
}
detail.Tags = append(detail.Tags, ti)
}
if err := tagRows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate tags: %w", err)
}
if detail.Tags == nil {
detail.Tags = []TagInfo{}
}
// Manifests.
mRows, err := d.Query(
`SELECT digest, media_type, size, created_at
FROM manifests WHERE repository_id = ?
ORDER BY created_at DESC`, repoID,
)
if err != nil {
return nil, fmt.Errorf("db: list repo manifests: %w", err)
}
defer func() { _ = mRows.Close() }()
for mRows.Next() {
var mi ManifestInfo
if err := mRows.Scan(&mi.Digest, &mi.MediaType, &mi.Size, &mi.CreatedAt); err != nil {
return nil, fmt.Errorf("db: scan manifest: %w", err)
}
detail.TotalSize += mi.Size
detail.Manifests = append(detail.Manifests, mi)
}
if err := mRows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate manifests: %w", err)
}
if detail.Manifests == nil {
detail.Manifests = []ManifestInfo{}
}
return detail, nil
}
// DeleteRepository deletes a repository and all its manifests, tags, and
// manifest_blobs. CASCADE handles the dependent rows.
func (d *DB) DeleteRepository(name string) error {
result, err := d.Exec(`DELETE FROM repositories WHERE name = ?`, name)
if err != nil {
return fmt.Errorf("db: delete repository: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: delete repository rows affected: %w", err)
}
if n == 0 {
return ErrRepoNotFound
}
return nil
}
// CreatePolicyRule inserts a new policy rule and returns its ID.
func (d *DB) CreatePolicyRule(rule PolicyRuleRow) (int64, error) {
body := ruleBody{
Effect: rule.Effect,
Roles: rule.Roles,
AccountTypes: rule.AccountTypes,
SubjectUUID: rule.SubjectUUID,
Actions: rule.Actions,
Repositories: rule.Repositories,
}
ruleJSON, err := json.Marshal(body)
if err != nil {
return 0, fmt.Errorf("db: marshal rule body: %w", err)
}
enabled := 0
if rule.Enabled {
enabled = 1
}
result, err := d.Exec(
`INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by)
VALUES (?, ?, ?, ?, ?)`,
rule.Priority, rule.Description, string(ruleJSON), enabled, nullIfEmpty(rule.CreatedBy),
)
if err != nil {
return 0, fmt.Errorf("db: create policy rule: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("db: policy rule last insert id: %w", err)
}
return id, nil
}
// GetPolicyRule returns a single policy rule by ID.
func (d *DB) GetPolicyRule(id int64) (*PolicyRuleRow, error) {
var row PolicyRuleRow
var ruleJSON string
var enabledInt int
var createdBy *string
err := d.QueryRow(
`SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
FROM policy_rules WHERE id = ?`, id,
).Scan(&row.ID, &row.Priority, &row.Description, &ruleJSON, &enabledInt, &createdBy, &row.CreatedAt, &row.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPolicyRuleNotFound
}
return nil, fmt.Errorf("db: get policy rule: %w", err)
}
row.Enabled = enabledInt == 1
if createdBy != nil {
row.CreatedBy = *createdBy
}
var body ruleBody
if err := json.Unmarshal([]byte(ruleJSON), &body); err != nil {
return nil, fmt.Errorf("db: parse rule_json for rule %d: %w", id, err)
}
row.Effect = body.Effect
row.Roles = body.Roles
row.AccountTypes = body.AccountTypes
row.SubjectUUID = body.SubjectUUID
row.Actions = body.Actions
row.Repositories = body.Repositories
return &row, nil
}
// ListPolicyRules returns all policy rules ordered by priority ascending.
func (d *DB) ListPolicyRules(limit, offset int) ([]PolicyRuleRow, error) {
if limit <= 0 {
limit = 50
}
rows, err := d.Query(
`SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
FROM policy_rules
ORDER BY priority ASC
LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("db: list policy rules: %w", err)
}
defer func() { _ = rows.Close() }()
var rules []PolicyRuleRow
for rows.Next() {
var row PolicyRuleRow
var ruleJSON string
var enabledInt int
var createdBy *string
if err := rows.Scan(&row.ID, &row.Priority, &row.Description, &ruleJSON, &enabledInt, &createdBy, &row.CreatedAt, &row.UpdatedAt); err != nil {
return nil, fmt.Errorf("db: scan policy rule: %w", err)
}
row.Enabled = enabledInt == 1
if createdBy != nil {
row.CreatedBy = *createdBy
}
var body ruleBody
if err := json.Unmarshal([]byte(ruleJSON), &body); err != nil {
return nil, fmt.Errorf("db: parse rule_json: %w", err)
}
row.Effect = body.Effect
row.Roles = body.Roles
row.AccountTypes = body.AccountTypes
row.SubjectUUID = body.SubjectUUID
row.Actions = body.Actions
row.Repositories = body.Repositories
rules = append(rules, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate policy rules: %w", err)
}
return rules, nil
}
// UpdatePolicyRule performs a partial update of a policy rule.
// Only non-zero/non-empty fields in the input are updated.
// Always updates updated_at.
func (d *DB) UpdatePolicyRule(id int64, updates PolicyRuleRow) error {
// First check the rule exists.
var exists int
err := d.QueryRow(`SELECT COUNT(*) FROM policy_rules WHERE id = ?`, id).Scan(&exists)
if err != nil {
return fmt.Errorf("db: check policy rule: %w", err)
}
if exists == 0 {
return ErrPolicyRuleNotFound
}
var setClauses []string
var args []any
if updates.Priority != 0 {
setClauses = append(setClauses, "priority = ?")
args = append(args, updates.Priority)
}
if updates.Description != "" {
setClauses = append(setClauses, "description = ?")
args = append(args, updates.Description)
}
// If any rule body fields are set, rebuild the full rule_json.
// Read the current value first, apply the update, then write back.
if updates.Effect != "" || updates.Actions != nil || updates.Roles != nil ||
updates.AccountTypes != nil || updates.Repositories != nil || updates.SubjectUUID != "" {
var currentJSON string
err := d.QueryRow(`SELECT rule_json FROM policy_rules WHERE id = ?`, id).Scan(&currentJSON)
if err != nil {
return fmt.Errorf("db: read current rule_json: %w", err)
}
var body ruleBody
if err := json.Unmarshal([]byte(currentJSON), &body); err != nil {
return fmt.Errorf("db: parse current rule_json: %w", err)
}
if updates.Effect != "" {
body.Effect = updates.Effect
}
if updates.Actions != nil {
body.Actions = updates.Actions
}
if updates.Roles != nil {
body.Roles = updates.Roles
}
if updates.AccountTypes != nil {
body.AccountTypes = updates.AccountTypes
}
if updates.Repositories != nil {
body.Repositories = updates.Repositories
}
if updates.SubjectUUID != "" {
body.SubjectUUID = updates.SubjectUUID
}
newJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("db: marshal updated rule_json: %w", err)
}
setClauses = append(setClauses, "rule_json = ?")
args = append(args, string(newJSON))
}
// Always update updated_at.
setClauses = append(setClauses, "updated_at = ?")
args = append(args, time.Now().UTC().Format("2006-01-02T15:04:05Z"))
query := fmt.Sprintf("UPDATE policy_rules SET %s WHERE id = ?", strings.Join(setClauses, ", "))
args = append(args, id)
_, err = d.Exec(query, args...)
if err != nil {
return fmt.Errorf("db: update policy rule: %w", err)
}
return nil
}
// SetPolicyRuleEnabled sets the enabled flag for a policy rule.
func (d *DB) SetPolicyRuleEnabled(id int64, enabled bool) error {
enabledInt := 0
if enabled {
enabledInt = 1
}
result, err := d.Exec(
`UPDATE policy_rules SET enabled = ?, updated_at = ? WHERE id = ?`,
enabledInt, time.Now().UTC().Format("2006-01-02T15:04:05Z"), id,
)
if err != nil {
return fmt.Errorf("db: set policy rule enabled: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: set policy rule enabled rows affected: %w", err)
}
if n == 0 {
return ErrPolicyRuleNotFound
}
return nil
}
// DeletePolicyRule deletes a policy rule by ID.
func (d *DB) DeletePolicyRule(id int64) error {
result, err := d.Exec(`DELETE FROM policy_rules WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("db: delete policy rule: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: delete policy rule rows affected: %w", err)
}
if n == 0 {
return ErrPolicyRuleNotFound
}
return nil
}