Files
certctl/internal/repository/postgres/auth.go
T
shankar0123 02438ad9e1 ci: floor raise + doc drift (Phase 3 closure — TEST-H1/H2/M1/M2/M3/M4/L1, ARCH-H3/L1/L2/L3/L4)
Twelve findings from the architecture diligence audit's Phase 3 bundle
closed in one PR. All touch the CI workflows + small doc-drift fixes
across the production Go tree + migration headers.

CI workflow changes
====================

TEST-H1 — Race detection on ./... -short
  .github/workflows/ci.yml:106 was a 9-package explicit list. Audit
  finding TEST-H1 flagged that 25+ packages (internal/auth/*,
  internal/repository/*, internal/mcp, internal/scep, internal/pkcs7,
  internal/api/router, internal/api/acme, internal/cli, internal/cms,
  internal/config, internal/deploy, internal/integration,
  internal/ratelimit, internal/secret, internal/trustanchor, all of
  cmd/) silently dropped off race coverage.
  Post-fix: 'go test -race -short ./... -count=1 -timeout 600s'.
  76 testing.Short() guards already cover testcontainers + live-DB
  integration suites, so -short keeps the long-running tests out.

TEST-H2 — Cross-platform build matrix
  New 'cross-platform-build' job in ci.yml. Matrix:
  ubuntu-latest + windows-latest + macos-latest, fail-fast: false.
  Builds cmd/server + cmd/agent + cmd/cli + cmd/mcp-server on each.
  Catches Windows-specific regressions (path separators, file
  permissions, exec.Command semantics) the pre-Phase-3 Ubuntu-only
  CI missed.

TEST-L1 — actions/setup-go cache: true (explicit)
  setup-go v5 defaults cache: true; making it explicit so a future
  setup-go upgrade can't silently flip it. Re-runs hit the Go module
  + build cache instead of recompiling cold.

TEST-M1 — Mutation-testing floor at 55%
  security-deep-scan.yml::go-mutesting step rewritten. Removed
  continue-on-error + per-package '|| true'. New post-loop check
  extracts every 'The mutation score is X.YZ' line and fails the
  step if any package drops below 0.55. Floor rationale: starter
  ratio catches major regressions without rejecting the audit's
  'this is OK' steady state; raise quarterly.

TEST-M2 — 3 advisory deep-scan gates promoted to blocking
  Removed continue-on-error: true from:
    - gosec (filtered to G201/G202/G304/G108 high-signal rules:
      SQL-injection + path-traversal + pprof-exposed)
    - osv-scanner (multi-ecosystem CVE; complements govulncheck
      which is already blocking in ci.yml)
    - trivy image scan (--severity HIGH,CRITICAL --exit-code 1)
  continue-on-error count: 15 → 11.
  ZAP / schemathesis / nuclei / testssl stay advisory because their
  false-positive rates on https://localhost:8443-targeted DAST runs
  are high.

TEST-M3 — Playwright harness stub
  web/package.json adds '@playwright/test' devDep + 'e2e' / 'e2e:install'
  npm scripts. web/playwright.config.ts ships single chromium project
  with webServer block pointing at 'npm run dev'. web/src/__tests__/
  e2e/smoke.spec.ts proves the harness wires through. The full 15-flow
  suite ships in frontend-design-audit Phase 8 (TEST-H1 in THAT audit);
  this is the wiring + a single smoke test as the regression floor.
  New Makefile target: 'make e2e-test'.

Doc/code drift fixes
====================

TEST-M4 + ARCH-L2 — Skip inventory artifact + CI guard
  scripts/skip-inventory.sh walks every t.Skip site under cmd/ +
  internal/ + deploy/test/ and emits docs/testing/skip-inventory.md
  grouped by package with file:line:expression triples. Current
  inventory: 142 t.Skip sites, 76 testing.Short() guards.
  scripts/ci-guards/skip-inventory-drift.sh regenerates and fails on
  diff (excluding the 'Last reviewed' timestamp line which drifts
  daily). The Markdown is the canonical acquisition-diligence artifact
  for 'what tests are being skipped and why.'

ARCH-H3 — MCP catalogue floor reconciliation
  Audit framing was '121 vs floor 150 — doc/code drift.' Live count
  via the test's actual regex over all 5 tool files (tools.go +
  tools_audit_fix.go + tools_auth.go + tools_auth_bundle2.go +
  tools_est.go): 155 unique 'Name: "certctl_*"' declarations.
  Pre-Phase-3 audit measured tools.go in isolation (121) and missed
  the other 4 files (+34 unique names). The test at
  internal/ciparity/surface_parity_test.go::TestSurfaceParity_MCP
  passes today (155 ≥ 150). Added a clarifying comment near
  mcpBaselineFloor explaining the measurement scope so future
  reviewers don't repeat the audit's framing error.
  STATUS: stale — no code drift, just a measurement scoping error in
  the audit.

ARCH-L1 — panic() rationale comments
  5 panic sites in production Go (excluding _test.go):
    - internal/repository/postgres/tx.go:84
    - internal/service/issuer.go:861 (mustJSON)
    - internal/service/est.go:728 (mustParseTime)
    - internal/service/acme.go:1288 (rand source failure — already documented)
    - internal/pkcs7/certrep.go:270 (OID marshal — already documented)
  Added ARCH-L1 rationale comments to the 3 sites that didn't have
  them. All 5 are defensible impossible-path / rethrow / hardcoded-
  constant guards.

ARCH-L3 — Migration IF-NOT-EXISTS carve-outs
  4 migrations skip the literal 'IF NOT EXISTS' token but ARE
  idempotent via different Postgres patterns:
    - 000014_policy_violation_severity_check.up.sql: ALTER TABLE
      ADD CONSTRAINT CHECK doesn't accept IF NOT EXISTS; idempotency
      via DROP CONSTRAINT IF EXISTS preamble.
    - 000018_audit_events_worm.up.sql: CREATE OR REPLACE FUNCTION
      + DROP TRIGGER IF EXISTS + CREATE TRIGGER + DO $$ pg_roles
      existence check. CREATE TRIGGER doesn't take IF NOT EXISTS.
    - 000030_rbac_admin_perms.up.sql: INSERT ... ON CONFLICT DO NOTHING.
    - 000039_audit_crit1_perms.up.sql: same INSERT + ON CONFLICT pattern.
  Added ARCH-L3 header comments to each explaining the carve-out so
  reviewers don't flag the missing literal token.
  STATUS: largely stale — migrations are already idempotent.

ARCH-L4 — TODO/FIXME → see #<descriptor>
  5 TODOs rewritten to the allowed 'see #<descriptor>' pattern:
    - internal/repository/postgres/auth.go:220 → see #bundle-2-scope-fk
    - internal/connector/discovery/gcpsm/gcpsm.go:547 → see #gcpsm-pagination
    - internal/service/audit.go:244 → see #audit-pagination-count
    - internal/service/job.go:295, 299 → see #validation-job-impl
  New CI guard scripts/ci-guards/no-todo-in-prod.sh grep-fails any
  new TODO/FIXME in cmd/ + internal/ (excluding _test.go); allows
  'see #N' / 'see #<descriptor>' patterns.

Sandbox limitation
==================
The 6.1 GB certctl working tree fills the sandbox volume; go1.25.10
toolchain download fails with 'no space left on device' (sandbox has
1.25.9; go.mod requires 1.25.10). Local 'go test' / 'go build' NOT
run in this commit. Operator must run 'make verify' on their
workstation before push per CLAUDE.md operating rules.

The smoke.spec.ts NOT executed in the sandbox (no chromium installed).
Operator runs 'cd web && npm install && npx playwright install
--with-deps chromium && npm run e2e' on first wire-up.

All CI guards (no-todo-in-prod, skip-inventory-drift, G-3
env-docs-drift, doc-rot-detector, and every existing guard) verified
clean by running each individually.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-TEST-H1,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-H2,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M1,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M2,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M3,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M4,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-L1,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-H3,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L1,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L2,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L3,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L4
2026-05-13 20:10:08 +00:00

737 lines
25 KiB
Go

package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
"github.com/certctl-io/certctl/internal/repository"
)
// canonicalPermissionSet is built once at package init from the
// authdomain.CanonicalPermissions catalogue. Lookup is O(1); used by
// PermissionRepository.IsCanonical so the service layer can fail-fast
// before issuing a DB round-trip.
var canonicalPermissionSet = func() map[string]struct{} {
m := make(map[string]struct{}, len(authdomain.CanonicalPermissions))
for _, p := range authdomain.CanonicalPermissions {
m[p] = struct{}{}
}
return m
}()
// =============================================================================
// TenantRepository
// =============================================================================
// TenantRepository is the postgres implementation of
// repository.TenantRepository.
type TenantRepository struct {
db *sql.DB
}
// NewTenantRepository constructs a TenantRepository.
func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: db}
}
func (r *TenantRepository) Get(ctx context.Context, id string) (*authdomain.Tenant, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, name, description, created_at, updated_at FROM tenants WHERE id = $1`, id)
var t authdomain.Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedAt, &t.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrAuthNotFound
}
return nil, fmt.Errorf("tenant.get: %w", err)
}
return &t, nil
}
func (r *TenantRepository) List(ctx context.Context) ([]*authdomain.Tenant, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, description, created_at, updated_at FROM tenants ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("tenant.list: %w", err)
}
defer rows.Close()
var out []*authdomain.Tenant
for rows.Next() {
var t authdomain.Tenant
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("tenant.list scan: %w", err)
}
out = append(out, &t)
}
return out, rows.Err()
}
func (r *TenantRepository) EnsureDefault(ctx context.Context) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO tenants (id, name, description)
VALUES ($1, 'default', 'Single-tenant default seeded by Bundle 1 Phase 1.')
ON CONFLICT (id) DO NOTHING
`, authdomain.DefaultTenantID)
return err
}
// =============================================================================
// RoleRepository
// =============================================================================
// RoleRepository is the postgres implementation of repository.RoleRepository.
type RoleRepository struct {
db *sql.DB
}
func NewRoleRepository(db *sql.DB) *RoleRepository {
return &RoleRepository{db: db}
}
func (r *RoleRepository) Get(ctx context.Context, id string) (*authdomain.Role, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, tenant_id, name, description, created_at, updated_at
FROM roles WHERE id = $1`, id)
return scanRole(row)
}
func (r *RoleRepository) GetByName(ctx context.Context, tenantID, name string) (*authdomain.Role, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, tenant_id, name, description, created_at, updated_at
FROM roles WHERE tenant_id = $1 AND name = $2`, tenantID, name)
return scanRole(row)
}
func (r *RoleRepository) List(ctx context.Context, tenantID string) ([]*authdomain.Role, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, tenant_id, name, description, created_at, updated_at
FROM roles WHERE tenant_id = $1 ORDER BY name`, tenantID)
if err != nil {
return nil, fmt.Errorf("role.list: %w", err)
}
defer rows.Close()
var out []*authdomain.Role
for rows.Next() {
var role authdomain.Role
if err := rows.Scan(&role.ID, &role.TenantID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt); err != nil {
return nil, fmt.Errorf("role.list scan: %w", err)
}
out = append(out, &role)
}
return out, rows.Err()
}
func (r *RoleRepository) Create(ctx context.Context, role *authdomain.Role) error {
if role.ID == "" {
role.ID = "r-" + uuid.NewString()
}
if role.TenantID == "" {
role.TenantID = authdomain.DefaultTenantID
}
now := time.Now().UTC()
if role.CreatedAt.IsZero() {
role.CreatedAt = now
}
if role.UpdatedAt.IsZero() {
role.UpdatedAt = now
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO roles (id, tenant_id, name, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, role.ID, role.TenantID, role.Name, role.Description, role.CreatedAt, role.UpdatedAt)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
return repository.ErrAuthDuplicateName
}
return fmt.Errorf("role.create: %w", err)
}
return nil
}
func (r *RoleRepository) Update(ctx context.Context, role *authdomain.Role) error {
role.UpdatedAt = time.Now().UTC()
res, err := r.db.ExecContext(ctx, `
UPDATE roles SET name = $1, description = $2, updated_at = $3
WHERE id = $4
`, role.Name, role.Description, role.UpdatedAt, role.ID)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
return repository.ErrAuthDuplicateName
}
return fmt.Errorf("role.update: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return repository.ErrAuthNotFound
}
return nil
}
func (r *RoleRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, id)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23503" {
return repository.ErrAuthRoleInUse
}
return fmt.Errorf("role.delete: %w", err)
}
return nil
}
func (r *RoleRepository) ListPermissions(ctx context.Context, roleID string) ([]*authdomain.RolePermission, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT rp.role_id, rp.permission_id, rp.scope_type, rp.scope_id
FROM role_permissions rp
WHERE rp.role_id = $1
ORDER BY rp.permission_id, rp.scope_type
`, roleID)
if err != nil {
return nil, fmt.Errorf("role.listPermissions: %w", err)
}
defer rows.Close()
var out []*authdomain.RolePermission
for rows.Next() {
var rp authdomain.RolePermission
var scopeType string
var scopeID sql.NullString
if err := rows.Scan(&rp.RoleID, &rp.PermissionID, &scopeType, &scopeID); err != nil {
return nil, fmt.Errorf("role.listPermissions scan: %w", err)
}
rp.ScopeType = authdomain.ScopeType(scopeType)
if scopeID.Valid {
s := scopeID.String
rp.ScopeID = &s
}
out = append(out, &rp)
}
return out, rows.Err()
}
func (r *RoleRepository) AddPermission(ctx context.Context, g *authdomain.RolePermission) error {
// see #bundle-2-scope-fk — Bundle 1 Phase 12 deferral. scope_id is NOT
// currently FK-constrained against the resource tables
// (certificate_profiles, issuers). This means an operator can
// grant a permission at scope_type=profile / scope_id=p-bogus
// without the bogus profile existing; the gate still works
// (no permission rows match the bogus scope at request time)
// but a strict 404 on grant would be cleaner. Adding the FK
// requires a migration that confirms every existing
// role_permissions row references a real resource and is
// tracked as Bundle 2 work. See
// cowork/auth-bundle-1-prompt.md negative-test path #12.
var scopeID interface{}
if g.ScopeID != nil {
scopeID = *g.ScopeID
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING
`, g.RoleID, g.PermissionID, string(g.ScopeType), scopeID)
if err != nil {
return fmt.Errorf("role.addPermission: %w", err)
}
return nil
}
func (r *RoleRepository) RemovePermission(ctx context.Context, g *authdomain.RolePermission) error {
var scopeIDArg interface{}
scopeClause := "scope_id IS NULL"
args := []interface{}{g.RoleID, g.PermissionID, string(g.ScopeType)}
if g.ScopeID != nil {
scopeClause = "scope_id = $4"
scopeIDArg = *g.ScopeID
args = append(args, scopeIDArg)
}
q := fmt.Sprintf(
`DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2 AND scope_type = $3 AND %s`,
scopeClause)
_, err := r.db.ExecContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("role.removePermission: %w", err)
}
return nil
}
func scanRole(row *sql.Row) (*authdomain.Role, error) {
var role authdomain.Role
if err := row.Scan(&role.ID, &role.TenantID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrAuthNotFound
}
return nil, fmt.Errorf("role scan: %w", err)
}
return &role, nil
}
// =============================================================================
// PermissionRepository
// =============================================================================
type PermissionRepository struct {
db *sql.DB
}
func NewPermissionRepository(db *sql.DB) *PermissionRepository {
return &PermissionRepository{db: db}
}
func (r *PermissionRepository) List(ctx context.Context) ([]*authdomain.Permission, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, namespace FROM permissions ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("permission.list: %w", err)
}
defer rows.Close()
var out []*authdomain.Permission
for rows.Next() {
var p authdomain.Permission
if err := rows.Scan(&p.ID, &p.Name, &p.Namespace); err != nil {
return nil, fmt.Errorf("permission.list scan: %w", err)
}
out = append(out, &p)
}
return out, rows.Err()
}
func (r *PermissionRepository) GetByName(ctx context.Context, name string) (*authdomain.Permission, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, name, namespace FROM permissions WHERE name = $1`, name)
var p authdomain.Permission
if err := row.Scan(&p.ID, &p.Name, &p.Namespace); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrAuthNotFound
}
return nil, fmt.Errorf("permission.getByName: %w", err)
}
return &p, nil
}
// IsCanonical satisfies repository.PermissionRepository.
func (r *PermissionRepository) IsCanonical(name string) bool {
_, ok := canonicalPermissionSet[name]
return ok
}
// =============================================================================
// ActorRoleRepository
// =============================================================================
type ActorRoleRepository struct {
db *sql.DB
}
func NewActorRoleRepository(db *sql.DB) *ActorRoleRepository {
return &ActorRoleRepository{db: db}
}
func (r *ActorRoleRepository) ListByActor(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]*authdomain.ActorRole, error) {
// Audit 2026-05-11 A-1 — include scope_type + scope_id in the
// SELECT so the GUI / MCP surface can render which scope an
// actor's grant is bound to. Pre-fix, these columns were
// persisted by Grant (HIGH-10 closure) but never surfaced on
// read — operators couldn't see what they configured.
rows, err := r.db.QueryContext(ctx, `
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id, scope_type, scope_id
FROM actor_roles
WHERE actor_id = $1 AND actor_type = $2 AND tenant_id = $3
ORDER BY granted_at
`, actorID, string(actorType), tenantID)
if err != nil {
return nil, fmt.Errorf("actorRole.listByActor: %w", err)
}
return scanActorRoles(rows)
}
func (r *ActorRoleRepository) ListByRole(ctx context.Context, roleID string) ([]*authdomain.ActorRole, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id, scope_type, scope_id
FROM actor_roles
WHERE role_id = $1
ORDER BY granted_at
`, roleID)
if err != nil {
return nil, fmt.Errorf("actorRole.listByRole: %w", err)
}
return scanActorRoles(rows)
}
func (r *ActorRoleRepository) Grant(ctx context.Context, ar *authdomain.ActorRole) error {
if ar.ID == "" {
ar.ID = "ar-" + uuid.NewString()
}
if ar.TenantID == "" {
ar.TenantID = authdomain.DefaultTenantID
}
if ar.GrantedAt.IsZero() {
ar.GrantedAt = time.Now().UTC()
}
if ar.GrantedBy == "" {
ar.GrantedBy = "system"
}
var expires interface{}
if ar.ExpiresAt != nil {
expires = *ar.ExpiresAt
}
// Audit 2026-05-10 HIGH-10 — per-actor scope columns. Default to
// "global"+NULL when the caller didn't supply them (back-compat
// with pre-migration code paths). Migration 000043's schema-level
// DEFAULT 'global' covers the same case; passing explicitly here
// makes the Go-level write deterministic.
scopeType := string(ar.ScopeType)
if scopeType == "" {
scopeType = string(authdomain.ScopeTypeGlobal)
}
var scopeID interface{}
if ar.ScopeID != nil && *ar.ScopeID != "" {
scopeID = *ar.ScopeID
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id, scope_type, scope_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (actor_id, actor_type, role_id, scope_type, scope_id, tenant_id) DO NOTHING
`, ar.ID, ar.ActorID, string(ar.ActorType), ar.RoleID, ar.GrantedAt, expires, ar.GrantedBy, ar.TenantID, scopeType, scopeID)
if err != nil {
return fmt.Errorf("actorRole.grant: %w", err)
}
return nil
}
// Revoke drops actor_roles rows. Audit 2026-05-11 A-4 — scope-aware
// revoke. The pre-fix SQL omitted (scope_type, scope_id) from the
// WHERE clause; combined with HIGH-10's UNIQUE (actor_id, actor_type,
// role_id, scope_type, scope_id, tenant_id) uniqueness extension, an
// operator who granted the same role to the same actor at two
// different scopes had no selective-revoke path — every Revoke call
// nuked both rows. The new behaviour:
//
// - opts.ScopeType == "" (legacy call shape): drop the scope from the
// WHERE clause; delete every variant. Zero-row delete is NOT an
// error (preserves the GUI's pre-A-4 idempotence contract).
//
// - opts.ScopeType != "": narrow WHERE with
// `scope_type = $5 AND scope_id IS NOT DISTINCT FROM $6` (the
// IS-NOT-DISTINCT-FROM handles the `global → scope_id IS NULL`
// case cleanly — Postgres `= NULL` would silently match nothing).
// Zero-row delete IS an error (ErrActorRoleNotFound, mapped to
// HTTP 404 upstream) so operators get feedback when they target a
// scope variant that doesn't exist.
func (r *ActorRoleRepository) Revoke(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, roleID, tenantID string, opts repository.ActorRoleRevokeOptions) error {
if opts.ScopeType == "" {
// Legacy "revoke all variants" path. Zero-row delete = no-op.
_, err := r.db.ExecContext(ctx, `
DELETE FROM actor_roles
WHERE actor_id = $1 AND actor_type = $2 AND role_id = $3 AND tenant_id = $4
`, actorID, string(actorType), roleID, tenantID)
if err != nil {
return fmt.Errorf("actorRole.revoke: %w", err)
}
return nil
}
// Scoped path. `scope_id IS NOT DISTINCT FROM $6` makes
// (global, NULL) match (global, NULL) cleanly — vanilla `=` would
// drop on NULL ≠ NULL.
var scopeID interface{}
if opts.ScopeID != nil && *opts.ScopeID != "" {
scopeID = *opts.ScopeID
}
res, err := r.db.ExecContext(ctx, `
DELETE FROM actor_roles
WHERE actor_id = $1
AND actor_type = $2
AND role_id = $3
AND tenant_id = $4
AND scope_type = $5
AND scope_id IS NOT DISTINCT FROM $6
`, actorID, string(actorType), roleID, tenantID, string(opts.ScopeType), scopeID)
if err != nil {
return fmt.Errorf("actorRole.revoke: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return repository.ErrActorRoleNotFound
}
return nil
}
func (r *ActorRoleRepository) ListDistinctActors(ctx context.Context, tenantID string) ([]repository.ActorWithRoles, error) {
if tenantID == "" {
tenantID = authdomain.DefaultTenantID
}
rows, err := r.db.QueryContext(ctx, `
SELECT actor_id, actor_type,
array_agg(role_id ORDER BY role_id) AS role_ids
FROM actor_roles
WHERE tenant_id = $1
AND (expires_at IS NULL OR expires_at > NOW())
GROUP BY actor_id, actor_type
ORDER BY actor_id ASC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("actorRole.listDistinctActors: %w", err)
}
defer rows.Close()
var out []repository.ActorWithRoles
for rows.Next() {
var a repository.ActorWithRoles
var actorType string
// pq.StringArray decodes the postgres array_agg result.
var roles pq.StringArray
if err := rows.Scan(&a.ActorID, &actorType, &roles); err != nil {
return nil, fmt.Errorf("actorRole.listDistinctActors scan: %w", err)
}
a.ActorType = authdomain.ActorTypeValue(actorType)
a.TenantID = tenantID
a.RoleIDs = []string(roles)
out = append(out, a)
}
return out, rows.Err()
}
func (r *ActorRoleRepository) AdminExists(ctx context.Context, tenantID string) (bool, error) {
if tenantID == "" {
tenantID = authdomain.DefaultTenantID
}
// Exclude the seeded synthetic demo actor so a demo deploy that
// later switches to api-key mode can still bootstrap the first
// real admin. Matches the carve-out documented on the interface.
var count int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM actor_roles
WHERE role_id = $1
AND tenant_id = $2
AND actor_id != $3
AND (expires_at IS NULL OR expires_at > NOW())
`, authdomain.RoleIDAdmin, tenantID, authdomain.DemoAnonActorID).Scan(&count)
if err != nil {
return false, fmt.Errorf("actorRole.adminExists: %w", err)
}
return count > 0, nil
}
func (r *ActorRoleRepository) EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]repository.EffectivePermission, error) {
// Audit 2026-05-11 A-1 — effective scope is the intersection of
// the actor-role's scope (ar.scope_*) AND the role-permission's
// scope (rp.scope_*). Pre-fix, only rp.scope_* was read; an
// actor granted r-operator scoped to profile=p-prod silently
// got every r-operator permission at every scope rp emitted
// (typically global), defeating HIGH-10's per-actor scope knob.
//
// Matching rules (the inner CASE encodes them):
//
// ar.scope rp.scope effective_scope
// ───────── ───────── ──────────────────────
// global global global / NULL
// global profile=X profile=X (rp narrows)
// profile=X global profile=X (ar narrows)
// profile=X profile=X profile=X (both agree)
// profile=X profile=Y ROW DROPPED (disjoint scopes — no permission flows)
// profile=X issuer=* ROW DROPPED (scope-type mismatch)
//
// The HAVING-style filter is implemented via a subquery — Postgres
// doesn't allow referencing a CASE alias from HAVING in a SELECT
// DISTINCT context without a wrapping CTE.
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT permission_name, effective_scope_type, effective_scope_id
FROM (
SELECT
p.name AS permission_name,
CASE
WHEN ar.scope_type = 'global' AND rp.scope_type = 'global' THEN 'global'
WHEN ar.scope_type = 'global' THEN rp.scope_type
WHEN rp.scope_type = 'global' THEN ar.scope_type
WHEN ar.scope_type = rp.scope_type AND ar.scope_id IS NOT DISTINCT FROM rp.scope_id THEN ar.scope_type
ELSE NULL
END AS effective_scope_type,
CASE
WHEN ar.scope_type = 'global' AND rp.scope_type = 'global' THEN NULL
WHEN ar.scope_type = 'global' THEN rp.scope_id
WHEN rp.scope_type = 'global' THEN ar.scope_id
WHEN ar.scope_type = rp.scope_type AND ar.scope_id IS NOT DISTINCT FROM rp.scope_id THEN ar.scope_id
ELSE NULL
END AS effective_scope_id
FROM actor_roles ar
JOIN role_permissions rp ON rp.role_id = ar.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ar.actor_id = $1
AND ar.actor_type = $2
AND ar.tenant_id = $3
AND (ar.expires_at IS NULL OR ar.expires_at > NOW())
) AS intersected
WHERE effective_scope_type IS NOT NULL
`, actorID, string(actorType), tenantID)
if err != nil {
return nil, fmt.Errorf("actorRole.effective: %w", err)
}
defer rows.Close()
var out []repository.EffectivePermission
for rows.Next() {
var ep repository.EffectivePermission
var scopeType string
var scopeID sql.NullString
if err := rows.Scan(&ep.PermissionName, &scopeType, &scopeID); err != nil {
return nil, fmt.Errorf("actorRole.effective scan: %w", err)
}
ep.ScopeType = authdomain.ScopeType(scopeType)
if scopeID.Valid {
s := scopeID.String
ep.ScopeID = &s
}
out = append(out, ep)
}
return out, rows.Err()
}
func scanActorRoles(rows *sql.Rows) ([]*authdomain.ActorRole, error) {
defer rows.Close()
var out []*authdomain.ActorRole
for rows.Next() {
var ar authdomain.ActorRole
var actorType, scopeType string
var expires sql.NullTime
var scopeID sql.NullString
// Audit 2026-05-11 A-1 — scope_type + scope_id are persisted
// by Grant (HIGH-10 closure, migration 000043). Pre-fix they
// were never scanned, so callers received ActorRole with
// zero-value scope fields regardless of what the row held.
// EffectivePermissions narrowing depends on these being
// populated correctly.
if err := rows.Scan(&ar.ID, &ar.ActorID, &actorType, &ar.RoleID, &ar.GrantedAt, &expires, &ar.GrantedBy, &ar.TenantID, &scopeType, &scopeID); err != nil {
return nil, fmt.Errorf("actorRole scan: %w", err)
}
ar.ActorType = authdomain.ActorTypeValue(actorType)
if expires.Valid {
t := expires.Time
ar.ExpiresAt = &t
}
ar.ScopeType = authdomain.ScopeType(scopeType)
if scopeID.Valid {
s := scopeID.String
ar.ScopeID = &s
}
out = append(out, &ar)
}
return out, rows.Err()
}
// =============================================================================
// APIKeyRepository (Bundle 1 Phase 6 — bootstrap path)
// =============================================================================
// APIKeyRepository is the postgres implementation of
// repository.APIKeyRepository. Stores SHA-256 hashes only; the
// plaintext key value is never persisted.
type APIKeyRepository struct {
db *sql.DB
}
// NewAPIKeyRepository constructs an APIKeyRepository.
func NewAPIKeyRepository(db *sql.DB) *APIKeyRepository {
return &APIKeyRepository{db: db}
}
func (r *APIKeyRepository) Create(ctx context.Context, k *authdomain.APIKey) error {
if k.ID == "" {
k.ID = "ak-" + uuid.NewString()
}
if k.TenantID == "" {
k.TenantID = authdomain.DefaultTenantID
}
if k.CreatedAt.IsZero() {
k.CreatedAt = time.Now().UTC()
}
var expires interface{}
if k.ExpiresAt != nil {
expires = *k.ExpiresAt
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO api_keys (id, name, key_hash, tenant_id, admin, created_by, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, k.ID, k.Name, k.KeyHash, k.TenantID, k.Admin, k.CreatedBy, k.CreatedAt, expires)
if err != nil {
// Translate UNIQUE-constraint violations to the canonical
// auth sentinel so the service layer can return 409.
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return repository.ErrAuthDuplicateName
}
return fmt.Errorf("apiKey.create: %w", err)
}
return nil
}
func (r *APIKeyRepository) GetByName(ctx context.Context, name string) (*authdomain.APIKey, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, name, key_hash, tenant_id, admin, created_by, created_at, expires_at, last_used_at
FROM api_keys WHERE name = $1
`, name)
var k authdomain.APIKey
var expires, lastUsed sql.NullTime
if err := row.Scan(&k.ID, &k.Name, &k.KeyHash, &k.TenantID, &k.Admin, &k.CreatedBy, &k.CreatedAt, &expires, &lastUsed); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrAuthNotFound
}
return nil, fmt.Errorf("apiKey.getByName: %w", err)
}
if expires.Valid {
t := expires.Time
k.ExpiresAt = &t
}
if lastUsed.Valid {
t := lastUsed.Time
k.LastUsedAt = &t
}
return &k, nil
}
func (r *APIKeyRepository) List(ctx context.Context, tenantID string) ([]*authdomain.APIKey, error) {
if tenantID == "" {
tenantID = authdomain.DefaultTenantID
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, key_hash, tenant_id, admin, created_by, created_at, expires_at, last_used_at
FROM api_keys WHERE tenant_id = $1 ORDER BY created_at DESC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("apiKey.list: %w", err)
}
defer rows.Close()
var out []*authdomain.APIKey
for rows.Next() {
var k authdomain.APIKey
var expires, lastUsed sql.NullTime
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.TenantID, &k.Admin, &k.CreatedBy, &k.CreatedAt, &expires, &lastUsed); err != nil {
return nil, fmt.Errorf("apiKey.list scan: %w", err)
}
if expires.Valid {
t := expires.Time
k.ExpiresAt = &t
}
if lastUsed.Valid {
t := lastUsed.Time
k.LastUsedAt = &t
}
out = append(out, &k)
}
return out, rows.Err()
}
func (r *APIKeyRepository) Delete(ctx context.Context, name string) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM api_keys WHERE name = $1`, name)
if err != nil {
return fmt.Errorf("apiKey.delete: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return repository.ErrAuthNotFound
}
return nil
}