Files
certctl/internal/repository/auth.go
T
shankar0123 19497eef87 auth-bundle-1 Phase 1: RBAC schema + domain types + repository layer
Bundle 1 / Phase 1: ships the RBAC primitive as schema + domain types + repo layer. Service-layer wiring lands in Phase 2; middleware integration in Phase 3.

Schema (migrations/000029_rbac.up.sql, 272 lines, idempotent, transaction-wrapped):

tenants, roles, permissions, role_permissions, actor_roles. TEXT primary keys with prefixes (t-, r-, p-, ar-) per CLAUDE.md Architecture Decisions. TIMESTAMPTZ time columns. FK cascade explicit (tenant CASCADE, role RESTRICT, actor CASCADE). Three-value scope_type CHECK ('global', 'profile', 'issuer') matched 1:1 with internal/domain/auth.ScopeType. UNIQUE(tenant_id, name) on roles, UNIQUE(name) on permissions, UNIQUE(actor_id, actor_type, role_id, tenant_id) on actor_roles.

Seeds: t-default tenant, 7 default roles (admin, operator, viewer, agent, mcp, cli, auditor), 33-permission canonical catalogue (cert.* / profile.* / issuer.* / target.* / agent.* / audit.* / auth.role.* / auth.key.* / auth.bootstrap.use), full role->permission grant matrix at global scope. Demo-mode preservation: actor-demo-anon seeded with admin role unconditionally; Phase 3 wires the auth middleware to inject this actor into the context when CERTCTL_AUTH_TYPE=none. Reserved system actor; Phase 4 API rejects mutations / deletions targeting it with 409 Conflict.

Domain types (internal/domain/auth/{types,validate,validate_test}.go):

Tenant, Role, Permission, RolePermission, ActorRole structs with JSON tags. ScopeType enum (global/profile/issuer). ActorTypeValue mirrors internal/domain.ActorType to avoid an import cycle. CanonicalPermissions slice + DefaultRoles map are the single source of truth referenced by the migration; validate_test.go pins (a) no duplicate permissions, (b) every default-role permission is canonical, (c) admin holds the full catalogue, (d) seeded IDs carry the prefix convention, (e) ScopeType enum has exactly 3 values matching the CHECK constraint.

Extended internal/domain/audit.go: added ActorTypeAPIKey + ActorTypeAnonymous to the existing User/System/Agent enum so the audit trail can distinguish API-key requests from federated humans (Bundle 2 OIDC) and demo-mode (CERTCTL_AUTH_TYPE=none). Existing code that records actor_type=User keeps working; new APIKey value used by Bundle 1 Phase 3 middleware.

Repository layer (internal/repository/auth.go + internal/repository/postgres/auth.go):

TenantRepository (Get, List, EnsureDefault). RoleRepository (Get, GetByName, List, Create, Update, Delete with ErrAuthRoleInUse on FK RESTRICT, ListPermissions, AddPermission idempotent, RemovePermission). PermissionRepository (List, GetByName, IsCanonical for fail-fast catalog check). ActorRoleRepository (ListByActor, ListByRole, Grant idempotent, Revoke, EffectivePermissions which is the JOIN that auth.RequirePermission will use in Phase 3 — returns deduplicated (permission, scope) triples honouring the not-yet-expired predicate so future time-bound grants work without code change). Sentinel errors ErrAuthNotFound, ErrAuthDuplicateName, ErrAuthRoleInUse, ErrAuthReservedActor, ErrAuthUnknownPermission for handler-layer 404/409/400 mapping.

Verification: gofmt clean, go vet ./... clean, go test -short ./internal/domain/auth ./internal/repository/postgres pass. Integration tests against a live Postgres are gated by testing.Short() per repo convention; Phase 12 wires the testcontainers harness for full e2e coverage.

Branch: dev/auth-bundle-1. Phase 0 was 99a012e (extract internal/auth/). Phase 2 (service layer) is the next bundle.
2026-05-09 16:00:08 +00:00

115 lines
5.3 KiB
Go

package repository
import (
"context"
"errors"
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
)
// Sentinel errors for the RBAC repositories. Postgres implementations
// translate SQLSTATE codes (23505 unique-violation, 23503 FK-violation,
// no-rows) into these so handler / service code branches via errors.Is.
var (
// ErrAuthNotFound is returned by Get / GetByName when no row matches.
// Maps to HTTP 404.
ErrAuthNotFound = errors.New("auth: row not found")
// ErrAuthDuplicateName is returned by Create when a UNIQUE constraint
// fires (e.g. roles.name within a tenant). Maps to HTTP 409.
ErrAuthDuplicateName = errors.New("auth: duplicate name")
// ErrAuthRoleInUse is returned by RoleRepository.Delete when active
// actor_roles still reference the role (FK ON DELETE RESTRICT).
// Maps to HTTP 409.
ErrAuthRoleInUse = errors.New("auth: role still has active actor assignments")
// ErrAuthReservedActor is returned when a mutation targets a system-
// reserved actor (currently `actor-demo-anon`). Maps to HTTP 409.
ErrAuthReservedActor = errors.New("auth: reserved system actor cannot be modified")
// ErrAuthUnknownPermission is returned when a RolePermission grant
// references a permission name not in the canonical catalog.
// Maps to HTTP 400.
ErrAuthUnknownPermission = errors.New("auth: permission not in canonical catalog")
)
// TenantRepository wraps the tenants table. Bundle 1 ships single-tenant
// (one seeded `t-default`); the future managed-service offering activates
// multi-tenant by inserting additional tenants.
type TenantRepository interface {
Get(ctx context.Context, id string) (*authdomain.Tenant, error)
List(ctx context.Context) ([]*authdomain.Tenant, error)
EnsureDefault(ctx context.Context) error
}
// RoleRepository wraps the roles + role_permissions tables.
type RoleRepository interface {
Get(ctx context.Context, id string) (*authdomain.Role, error)
GetByName(ctx context.Context, tenantID, name string) (*authdomain.Role, error)
List(ctx context.Context, tenantID string) ([]*authdomain.Role, error)
Create(ctx context.Context, role *authdomain.Role) error
Update(ctx context.Context, role *authdomain.Role) error
// Delete fails with ErrAuthRoleInUse when active actor_roles still
// reference the role (FK ON DELETE RESTRICT).
Delete(ctx context.Context, id string) error
// ListPermissions returns the (Permission, ScopeType, ScopeID)
// triples granted to the role.
ListPermissions(ctx context.Context, roleID string) ([]*authdomain.RolePermission, error)
// AddPermission creates a row in role_permissions. ON CONFLICT DO
// NOTHING preserves idempotency for re-applied seeds.
AddPermission(ctx context.Context, grant *authdomain.RolePermission) error
// RemovePermission deletes a specific (role, permission, scope) row.
RemovePermission(ctx context.Context, grant *authdomain.RolePermission) error
}
// PermissionRepository wraps the permissions table.
type PermissionRepository interface {
List(ctx context.Context) ([]*authdomain.Permission, error)
GetByName(ctx context.Context, name string) (*authdomain.Permission, error)
// IsCanonical returns true when name is in
// authdomain.CanonicalPermissions. The migration seeds the catalog;
// this is an in-memory check so callers (RoleService.AddPermission)
// can fail-fast without a DB roundtrip.
IsCanonical(name string) bool
}
// ActorRoleRepository wraps the actor_roles table.
type ActorRoleRepository interface {
// ListByActor returns all standing role grants for an actor.
ListByActor(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]*authdomain.ActorRole, error)
// ListByRole returns all actors holding a given role. Used by
// RoleService.Delete to enforce the in-use guard.
ListByRole(ctx context.Context, roleID string) ([]*authdomain.ActorRole, error)
// Grant creates an actor_roles row. Idempotent via ON CONFLICT.
// The reserved actor `actor-demo-anon` admin grant is seeded by
// the migration; this method will create additional grants for it
// only if the operator explicitly wires that, which the API
// layer rejects.
Grant(ctx context.Context, ar *authdomain.ActorRole) error
// Revoke deletes an actor_roles row by (actor_id, actor_type,
// role_id, tenant_id). The API layer must reject revocations
// targeting `actor-demo-anon` to preserve the demo path.
Revoke(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, roleID, tenantID string) error
// EffectivePermissions returns the deduplicated set of
// (permission_name, scope_type, scope_id) triples granted to the
// actor across all roles they hold. The middleware-level
// auth.RequirePermission gate (Phase 3) calls this on every
// gated request; implementations should cache or use SQL JOINs
// for performance.
EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]EffectivePermission, error)
}
// EffectivePermission is the (permission, scope) pair returned by
// ActorRoleRepository.EffectivePermissions. Multiple actor_roles rows
// may grant the same permission at different scopes; callers receive
// every grant and the matcher handles "global beats specific" semantics.
type EffectivePermission struct {
PermissionName string
ScopeType authdomain.ScopeType
ScopeID *string // NULL = global
}