mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
02438ad9e1
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
737 lines
25 KiB
Go
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
|
|
}
|