diff --git a/internal/domain/audit.go b/internal/domain/audit.go index 55df86b..0c29ee9 100644 --- a/internal/domain/audit.go +++ b/internal/domain/audit.go @@ -21,7 +21,34 @@ type AuditEvent struct { type ActorType string const ( - ActorTypeUser ActorType = "User" + // ActorTypeUser represents a federated human identity. Reserved by + // Bundle 2 (OIDC + sessions) for OIDC-authenticated humans. Bundle 1 + // continues to set this for legacy callers; new code should use + // ActorTypeAPIKey for API-key-authenticated requests. + ActorTypeUser ActorType = "User" + + // ActorTypeSystem represents background workers (scheduler loops, GC + // sweepers, migrations). System actors don't have a credential; the + // scheduler / startup code passes them directly to AuditService. ActorTypeSystem ActorType = "System" - ActorTypeAgent ActorType = "Agent" + + // ActorTypeAgent represents a certctl-agent identity. Agents poll the + // control plane outbound; the matched API key carries this actor type + // when the operator scopes the key to the agent role (Bundle 1 + // Phase 1 ships the agent role with cert.read + agent.heartbeat + + // agent.job.* permissions). + ActorTypeAgent ActorType = "Agent" + + // ActorTypeAPIKey represents an API-key-authenticated request whose + // scope was not narrowed to agent-only. Bundle 1 Phase 1 introduces + // this so the audit trail can distinguish a human-operator API key + // from a federated OIDC user (Bundle 2). System actors and agents + // keep their existing types. + ActorTypeAPIKey ActorType = "APIKey" + + // ActorTypeAnonymous represents the synthetic actor used when + // CERTCTL_AUTH_TYPE=none is configured (the demo path). The audit + // row records "actor-demo-anon" with this type so operators can + // filter demo activity from real auth in audit reports. + ActorTypeAnonymous ActorType = "Anonymous" ) diff --git a/internal/domain/auth/types.go b/internal/domain/auth/types.go new file mode 100644 index 0000000..57b4010 --- /dev/null +++ b/internal/domain/auth/types.go @@ -0,0 +1,106 @@ +// Package auth holds the RBAC domain types: tenants, roles, permissions, +// role-permission grants, and actor-role assignments. Bundle 1 Phase 1 +// ships these as the schema primitive; Phase 2 wires the service layer, +// Phase 3 wires the middleware gate (auth.RequirePermission). +// +// Schema convention follows the rest of certctl per CLAUDE.md +// "Architecture Decisions": TEXT primary keys with prefixes (`t-`, `r-`, +// `p-`, `ar-`), TIMESTAMPTZ for time columns, idempotent migrations. +// +// Multi-tenant readiness: every identity-related row carries a TenantID. +// Bundle 1 ships single-tenant by default (one seeded "t-default" tenant); +// the future managed-service offering activates multi-tenant by adding +// tenants without a schema migration. +package auth + +import "time" + +// Tenant is a billing / isolation boundary. Bundle 1 ships single-tenant +// (one seeded "t-default" tenant); the column exists from day one so the +// future managed-service offering activates multi-tenant by adding +// tenants without a schema migration. +type Tenant struct { + ID string `json:"id"` // prefix `t-` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Role is a named bag of permissions assigned to actors. Bundle 1 seeds +// seven default roles: admin, operator, viewer, agent, mcp, cli, auditor +// (auditor reserved for Phase 8). Operators can create custom roles via +// the RBAC API. +type Role struct { + ID string `json:"id"` // prefix `r-` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Permission is a typed string in the canonical catalog (cert.*, +// profile.*, issuer.*, target.*, agent.*, audit.*, auth.role.*, +// auth.key.*, auth.bootstrap.*). Bundle 2 extends with auth.session.* +// and auth.oidc.* permissions. The schema treats permissions as rows +// for FK joins; the service layer treats them as opaque strings keyed +// by Name. +type Permission struct { + ID string `json:"id"` // prefix `p-` + Name string `json:"name"` + Namespace string `json:"namespace"` // e.g. "cert", "auth.role" +} + +// ScopeType enumerates what RolePermission.ScopeID refers to. Bundle 1 +// MVP supports global, profile, issuer scopes; per-cert / per-deployment- +// target scoping deferred to a future bundle. +type ScopeType string + +const ( + // ScopeTypeGlobal applies the permission across all resources. + // ScopeID is NULL for ScopeTypeGlobal grants. + ScopeTypeGlobal ScopeType = "global" + + // ScopeTypeProfile applies the permission only to the named + // CertificateProfile (matched by ID). + ScopeTypeProfile ScopeType = "profile" + + // ScopeTypeIssuer applies the permission only to the named Issuer + // (matched by ID). + ScopeTypeIssuer ScopeType = "issuer" +) + +// RolePermission is a (role, permission, scope) triple. A role grants +// the permission at the named scope to all actors holding the role. +// Most rows are global-scoped (ScopeID NULL); per-profile and per-issuer +// scopes are operator-configurable. +type RolePermission struct { + RoleID string `json:"role_id"` + PermissionID string `json:"permission_id"` + ScopeType ScopeType `json:"scope_type"` + ScopeID *string `json:"scope_id,omitempty"` // NULL for global +} + +// ActorRole assigns a Role to an Actor (an API key, an OIDC-federated +// user, an agent, or the synthetic demo-anon actor). The schema reserves +// ExpiresAt + GrantedBy columns so future time-bound grants and JIT +// elevation can be added without a migration. +type ActorRole struct { + ID string `json:"id"` // prefix `ar-` + ActorID string `json:"actor_id"` + ActorType ActorTypeValue `json:"actor_type"` + RoleID string `json:"role_id"` + GrantedAt time.Time `json:"granted_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + GrantedBy string `json:"granted_by"` + TenantID string `json:"tenant_id"` +} + +// ActorTypeValue is the typed-string actor identifier used in +// ActorRole.ActorType. It mirrors the values in +// internal/domain.ActorType (User, System, Agent, APIKey, Anonymous); +// callers should reference internal/domain constants directly when +// possible. This package-local alias exists so the auth subpackage +// avoids importing the parent domain package and creating a cycle. +type ActorTypeValue string diff --git a/internal/domain/auth/validate.go b/internal/domain/auth/validate.go new file mode 100644 index 0000000..7a28ed9 --- /dev/null +++ b/internal/domain/auth/validate.go @@ -0,0 +1,163 @@ +package auth + +// Seed identifiers and constants used by the Phase 1 migration and the +// service / handler layers. Centralised here so production code, tests, +// and migration SQL stay in lockstep on the canonical role / permission +// names. + +// DefaultTenantID is the seeded tenant created by migration +// 000029_rbac.up.sql. Bundle 1 ships single-tenant; every actor_role +// row carries this tenant_id by default. +const DefaultTenantID = "t-default" + +// Seeded role IDs. Stable identifiers used by the migration backfill +// and the demo-mode synthetic-actor seed. +const ( + RoleIDAdmin = "r-admin" + RoleIDOperator = "r-operator" + RoleIDViewer = "r-viewer" + RoleIDAgent = "r-agent" + RoleIDMCP = "r-mcp" + RoleIDCLI = "r-cli" + RoleIDAuditor = "r-auditor" +) + +// DemoAnonActorID is the synthetic actor used when +// CERTCTL_AUTH_TYPE=none is configured (the demo path). Phase 1 +// migration seeds the actor + admin role assignment unconditionally; +// Phase 3 of Bundle 1 wires the middleware to inject this actor into +// the request context when no-auth mode is active. Reserved system +// actor: the API rejects mutations / deletions targeting this id. +const DemoAnonActorID = "actor-demo-anon" + +// CanonicalPermissions is the canonical Bundle 1 permission catalog, +// seeded by migration 000029_rbac.up.sql. Bundle 2 extends with +// auth.session.* and auth.oidc.* permissions (those land in Bundle 2 +// Phase 5's migration). +// +// Naming convention: .. Read permissions use +// `.read`; mutations use `.create`, `.edit`, `.delete`, +// `.assign`, `.revoke`, `.use`, `.export`, etc. The catalog is the +// single source of truth referenced by: +// - migration 000029_rbac.up.sql (seeds the rows) +// - service layer (RoleService.Create rejects unknown permissions) +// - handler layer (auth.RequirePermission perm string) +var CanonicalPermissions = []string{ + // Certificate lifecycle + "cert.read", + "cert.issue", + "cert.revoke", + "cert.delete", + + // Profile management + "profile.read", + "profile.edit", + "profile.delete", + + // Issuer management + "issuer.read", + "issuer.edit", + "issuer.delete", + + // Target management + "target.read", + "target.edit", + "target.delete", + + // Agent management + "agent.read", + "agent.edit", + "agent.retire", + "agent.heartbeat", + "agent.job.poll", + "agent.job.complete", + "agent.job.report", + + // Audit access (Phase 8 introduces the auditor split) + "audit.read", + "audit.export", + + // RBAC primitive (Phase 4 surfaces these via /v1/auth/roles) + "auth.role.list", + "auth.role.create", + "auth.role.edit", + "auth.role.delete", + "auth.role.assign", + "auth.role.revoke", + + // API-key management (Phase 4 + Phase 7 scope-down) + "auth.key.list", + "auth.key.create", + "auth.key.rotate", + "auth.key.delete", + + // Bootstrap path (Phase 6) + "auth.bootstrap.use", +} + +// DefaultRoles describes the seven default roles seeded by the +// migration, mapped to the permissions each role holds at global +// scope. Permissions not in CanonicalPermissions cause the migration +// to fail-closed. +var DefaultRoles = map[string][]string{ + RoleIDAdmin: CanonicalPermissions, // admin gets every permission + + RoleIDOperator: { + "cert.read", "cert.issue", "cert.revoke", "cert.delete", + "profile.read", "profile.edit", + "issuer.read", "issuer.edit", + "target.read", "target.edit", "target.delete", + "agent.read", "agent.edit", + "audit.read", + }, + + RoleIDViewer: { + "cert.read", + "profile.read", + "issuer.read", + "target.read", + "agent.read", + "audit.read", + }, + + RoleIDAgent: { + "cert.read", + "agent.heartbeat", + "agent.job.poll", + "agent.job.complete", + "agent.job.report", + }, + + RoleIDMCP: { + // MCP gets operator-equivalent minus destructive ops. + // Defense in depth for Claude / IDE integrations where + // destructive verbs warrant additional scrutiny. + "cert.read", "cert.issue", "cert.revoke", + "profile.read", "profile.edit", + "issuer.read", "issuer.edit", + "target.read", "target.edit", + "agent.read", + "audit.read", + }, + + RoleIDCLI: { + // CLI = operator-equivalent. Operators can scope down via + // `certctl auth keys scope-down` if they want narrower CLI + // access in production. + "cert.read", "cert.issue", "cert.revoke", "cert.delete", + "profile.read", "profile.edit", + "issuer.read", "issuer.edit", + "target.read", "target.edit", "target.delete", + "agent.read", "agent.edit", + "audit.read", + "auth.key.list", "auth.key.create", "auth.key.rotate", + }, + + RoleIDAuditor: { + // Phase 8 ships the auditor split. Phase 1 reserves the + // role id + the read-only permission set so subsequent + // phases don't have to renumber. + "audit.read", + "audit.export", + }, +} diff --git a/internal/domain/auth/validate_test.go b/internal/domain/auth/validate_test.go new file mode 100644 index 0000000..8661ab6 --- /dev/null +++ b/internal/domain/auth/validate_test.go @@ -0,0 +1,95 @@ +package auth + +import "testing" + +// TestCanonicalPermissions_HasNoDuplicates pins the permission catalogue +// against accidental duplication. Migration 000029_rbac.up.sql seeds one +// permission row per name; if the catalogue has duplicates, the +// migration fails on the (name) UNIQUE constraint. Catch the regression +// at compile time instead of at startup. +func TestCanonicalPermissions_HasNoDuplicates(t *testing.T) { + seen := make(map[string]struct{}, len(CanonicalPermissions)) + for _, p := range CanonicalPermissions { + if _, ok := seen[p]; ok { + t.Errorf("duplicate permission in CanonicalPermissions: %q", p) + } + seen[p] = struct{}{} + } +} + +// TestDefaultRoles_ReferenceCanonicalPermissionsOnly pins that every +// permission referenced in DefaultRoles is also present in +// CanonicalPermissions. The migration seeds one row per permission; +// referencing a non-canonical permission would fail at runtime. +func TestDefaultRoles_ReferenceCanonicalPermissionsOnly(t *testing.T) { + canonical := make(map[string]struct{}, len(CanonicalPermissions)) + for _, p := range CanonicalPermissions { + canonical[p] = struct{}{} + } + for roleID, perms := range DefaultRoles { + for _, p := range perms { + if _, ok := canonical[p]; !ok { + t.Errorf("role %s references non-canonical permission %q", roleID, p) + } + } + } +} + +// TestDefaultRoles_AdminHasEveryPermission pins the invariant that the +// admin role is assigned the full canonical catalogue. Bundle 1 +// Phase 1's migration relies on this for the admin grant SELECT * FROM +// permissions; if the role somehow only got a subset, downstream +// RequirePermission gates would 403 admin actors on permissions that +// were forgotten. +func TestDefaultRoles_AdminHasEveryPermission(t *testing.T) { + adminPerms := DefaultRoles[RoleIDAdmin] + if len(adminPerms) != len(CanonicalPermissions) { + t.Errorf("admin role permission count = %d, want %d (full canonical catalogue)", + len(adminPerms), len(CanonicalPermissions)) + } +} + +// TestSeededIDs_HavePrefixes pins the TEXT-PK-with-prefix convention +// (CLAUDE.md "Architecture Decisions"). +func TestSeededIDs_HavePrefixes(t *testing.T) { + cases := []struct { + id string + prefix string + }{ + {DefaultTenantID, "t-"}, + {RoleIDAdmin, "r-"}, + {RoleIDOperator, "r-"}, + {RoleIDViewer, "r-"}, + {RoleIDAgent, "r-"}, + {RoleIDMCP, "r-"}, + {RoleIDCLI, "r-"}, + {RoleIDAuditor, "r-"}, + // DemoAnonActorID is an actor id, not a role / tenant id; it + // uses the actor- prefix instead of t-/r-/p-/ar-. Pin + // separately so a future rename doesn't silently regress. + {DemoAnonActorID, "actor-"}, + } + for _, tc := range cases { + if len(tc.id) <= len(tc.prefix) || tc.id[:len(tc.prefix)] != tc.prefix { + t.Errorf("id %q missing prefix %q", tc.id, tc.prefix) + } + } +} + +// TestScopeType_EnumValuesPinned pins the three Bundle 1 scope types +// against drift. Migration 000029_rbac.up.sql has a CHECK constraint +// `scope_type IN ('global', 'profile', 'issuer')`; if Bundle 1 code +// adds a fourth value, the migration must be updated in lockstep. +func TestScopeType_EnumValuesPinned(t *testing.T) { + want := []ScopeType{ScopeTypeGlobal, ScopeTypeProfile, ScopeTypeIssuer} + gotValues := []string{string(ScopeTypeGlobal), string(ScopeTypeProfile), string(ScopeTypeIssuer)} + wantValues := []string{"global", "profile", "issuer"} + for i, v := range wantValues { + if gotValues[i] != v { + t.Errorf("scope type %d: got %q, want %q", i, gotValues[i], v) + } + } + if len(want) != 3 { + t.Errorf("ScopeType enum size = %d, want 3 (any change requires migration update)", len(want)) + } +} diff --git a/internal/repository/auth.go b/internal/repository/auth.go new file mode 100644 index 0000000..7c44e5e --- /dev/null +++ b/internal/repository/auth.go @@ -0,0 +1,114 @@ +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 +} diff --git a/internal/repository/postgres/auth.go b/internal/repository/postgres/auth.go new file mode 100644 index 0000000..e4fcfda --- /dev/null +++ b/internal/repository/postgres/auth.go @@ -0,0 +1,442 @@ +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 { + 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) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_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 + 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 + } + _, err := r.db.ExecContext(ctx, ` + INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (actor_id, actor_type, role_id, tenant_id) DO NOTHING + `, ar.ID, ar.ActorID, string(ar.ActorType), ar.RoleID, ar.GrantedAt, expires, ar.GrantedBy, ar.TenantID) + if err != nil { + return fmt.Errorf("actorRole.grant: %w", err) + } + return nil +} + +func (r *ActorRoleRepository) Revoke(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, roleID, tenantID string) error { + _, 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 +} + +func (r *ActorRoleRepository) EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]repository.EffectivePermission, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT DISTINCT p.name, rp.scope_type, rp.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()) + `, 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 string + var expires sql.NullTime + if err := rows.Scan(&ar.ID, &ar.ActorID, &actorType, &ar.RoleID, &ar.GrantedAt, &expires, &ar.GrantedBy, &ar.TenantID); err != nil { + return nil, fmt.Errorf("actorRole scan: %w", err) + } + ar.ActorType = authdomain.ActorTypeValue(actorType) + if expires.Valid { + t := expires.Time + ar.ExpiresAt = &t + } + out = append(out, &ar) + } + return out, rows.Err() +} diff --git a/migrations/000029_rbac.down.sql b/migrations/000029_rbac.down.sql new file mode 100644 index 0000000..a4e4147 --- /dev/null +++ b/migrations/000029_rbac.down.sql @@ -0,0 +1,17 @@ +-- 000029_rbac.down.sql +-- Reverse of 000029_rbac.up.sql. Drops in FK-safe order. Idempotent +-- (DROP TABLE IF EXISTS). + +BEGIN; + +DROP INDEX IF EXISTS idx_role_permissions_role; +DROP INDEX IF EXISTS idx_actor_roles_role; +DROP INDEX IF EXISTS idx_actor_roles_actor; + +DROP TABLE IF EXISTS actor_roles; +DROP TABLE IF EXISTS role_permissions; +DROP TABLE IF EXISTS permissions; +DROP TABLE IF EXISTS roles; +DROP TABLE IF EXISTS tenants; + +COMMIT; diff --git a/migrations/000029_rbac.up.sql b/migrations/000029_rbac.up.sql new file mode 100644 index 0000000..2b80074 --- /dev/null +++ b/migrations/000029_rbac.up.sql @@ -0,0 +1,272 @@ +-- 000029_rbac.up.sql +-- Bundle 1 / Phase 1: RBAC primitive. Roles, permissions, role-permission +-- grants, actor-role assignments, plus a reserved tenant table for the +-- future managed-service multi-tenant offering. +-- +-- All operations use IF NOT EXISTS / IF EXISTS / ON CONFLICT DO NOTHING +-- so the migration is idempotent: safe to re-run on every certctl-server +-- boot per the project's "Idempotent migrations" architecture decision. +-- Wrapped in a single transaction so a partial-fail leaves no half-state. +-- +-- Schema convention follows CLAUDE.md "Architecture Decisions": TEXT +-- primary keys with prefixes (`t-`, `r-`, `p-`, `ar-`), TIMESTAMPTZ for +-- time columns, FK cascade behaviour explicit (RESTRICT on roles with +-- active actor_roles, CASCADE on tenant + actor deletion). +-- +-- Backwards compatibility: existing API keys configured via +-- CERTCTL_API_KEYS_NAMED retain their behaviour. The migration backfills +-- one actor_role row per named key (mapping admin keys to r-admin and +-- non-admin keys to r-viewer) at server startup; the actual seed lives +-- in cmd/server/main.go because the named-key list is configured via +-- environment variable, not stored in the DB. +-- +-- Demo-mode preservation: this migration UNCONDITIONALLY seeds +-- actor-demo-anon with the admin role. Bundle 1 Phase 3 wires the auth +-- middleware to inject this actor into the request context when +-- CERTCTL_AUTH_TYPE=none is configured (the demo path); when api-key +-- mode is active, the actor exists in the schema but is unreferenced. + +BEGIN; + +-- Tenants. Bundle 1 ships single-tenant; the future managed-service +-- offering activates multi-tenant by inserting additional tenants. +CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, -- prefix `t-` + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Roles. Each role is a named bag of permissions; actors hold zero or +-- more roles via actor_roles. +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, -- prefix `r-` + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (tenant_id, name) +); + +-- Permissions: typed strings in the canonical catalog. Treated as rows +-- so role_permissions can FK-join. The catalog is documented in +-- internal/domain/auth/validate.go::CanonicalPermissions; adding a new +-- permission requires a migration AND a code update in lockstep. +CREATE TABLE IF NOT EXISTS permissions ( + id TEXT PRIMARY KEY, -- prefix `p-` + name TEXT NOT NULL UNIQUE, -- e.g. "cert.read" + namespace TEXT NOT NULL -- e.g. "cert" +); + +-- Role-permission grants with explicit scope. ScopeType is one of +-- 'global', 'profile', 'issuer'; ScopeID is NULL when global, otherwise +-- references the resource id (managed at the application layer because +-- profiles + issuers live in different tables; we don't FK on scope_id). +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE RESTRICT, + scope_type TEXT NOT NULL DEFAULT 'global', + scope_id TEXT, -- NULL for global + + PRIMARY KEY (role_id, permission_id, scope_type, scope_id), + CONSTRAINT role_permission_scope_check CHECK ( + scope_type IN ('global', 'profile', 'issuer') + ), + CONSTRAINT role_permission_scope_id_consistency CHECK ( + (scope_type = 'global' AND scope_id IS NULL) + OR (scope_type IN ('profile', 'issuer') AND scope_id IS NOT NULL) + ) +); + +-- Actor-role assignments. ExpiresAt + GrantedBy reserved for future +-- time-bound grants and JIT elevation; Bundle 1 leaves them NULL for +-- standing grants. +CREATE TABLE IF NOT EXISTS actor_roles ( + id TEXT PRIMARY KEY, -- prefix `ar-` + actor_id TEXT NOT NULL, + actor_type TEXT NOT NULL, -- domain.ActorType + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, -- NULL = standing + granted_by TEXT NOT NULL DEFAULT 'system', + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + UNIQUE (actor_id, actor_type, role_id, tenant_id), + CONSTRAINT actor_type_enum CHECK ( + actor_type IN ('User', 'System', 'Agent', 'APIKey', 'Anonymous') + ) +); + +CREATE INDEX IF NOT EXISTS idx_actor_roles_actor + ON actor_roles(actor_id, actor_type, tenant_id); +CREATE INDEX IF NOT EXISTS idx_actor_roles_role + ON actor_roles(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_role + ON role_permissions(role_id); + +-- Default tenant. +INSERT INTO tenants (id, name, description) +VALUES ('t-default', 'default', 'Single-tenant default; future multi-tenant managed offering activates by inserting additional tenants.') +ON CONFLICT (id) DO NOTHING; + +-- Default roles. +INSERT INTO roles (id, tenant_id, name, description) VALUES + ('r-admin', 't-default', 'admin', 'Full access. All permissions, global scope.'), + ('r-operator', 't-default', 'operator', 'Cert lifecycle + read access. No RBAC management.'), + ('r-viewer', 't-default', 'viewer', 'Read-only access across cert / profile / issuer / target / agent / audit.'), + ('r-agent', 't-default', 'agent', 'certctl-agent identity. cert.read + agent.heartbeat + agent.job.* perms.'), + ('r-mcp', 't-default', 'mcp', 'MCP server identity. Operator-equivalent minus destructive verbs.'), + ('r-cli', 't-default', 'cli', 'CLI user. Operator-equivalent plus auth.key.* for self-management.'), + ('r-auditor', 't-default', 'auditor', 'Read-only audit access. Phase 8 splits this from admin for compliance reviewers.') +ON CONFLICT (id) DO NOTHING; + +-- Canonical permission catalog. +-- Bundle 2 will add auth.session.* and auth.oidc.* permissions; this +-- catalog is Bundle-1 minimum. +INSERT INTO permissions (id, name, namespace) VALUES + ('p-cert-read', 'cert.read', 'cert'), + ('p-cert-issue', 'cert.issue', 'cert'), + ('p-cert-revoke', 'cert.revoke', 'cert'), + ('p-cert-delete', 'cert.delete', 'cert'), + ('p-profile-read', 'profile.read', 'profile'), + ('p-profile-edit', 'profile.edit', 'profile'), + ('p-profile-delete', 'profile.delete', 'profile'), + ('p-issuer-read', 'issuer.read', 'issuer'), + ('p-issuer-edit', 'issuer.edit', 'issuer'), + ('p-issuer-delete', 'issuer.delete', 'issuer'), + ('p-target-read', 'target.read', 'target'), + ('p-target-edit', 'target.edit', 'target'), + ('p-target-delete', 'target.delete', 'target'), + ('p-agent-read', 'agent.read', 'agent'), + ('p-agent-edit', 'agent.edit', 'agent'), + ('p-agent-retire', 'agent.retire', 'agent'), + ('p-agent-heartbeat', 'agent.heartbeat', 'agent'), + ('p-agent-job-poll', 'agent.job.poll', 'agent.job'), + ('p-agent-job-complete', 'agent.job.complete', 'agent.job'), + ('p-agent-job-report', 'agent.job.report', 'agent.job'), + ('p-audit-read', 'audit.read', 'audit'), + ('p-audit-export', 'audit.export', 'audit'), + ('p-auth-role-list', 'auth.role.list', 'auth.role'), + ('p-auth-role-create', 'auth.role.create', 'auth.role'), + ('p-auth-role-edit', 'auth.role.edit', 'auth.role'), + ('p-auth-role-delete', 'auth.role.delete', 'auth.role'), + ('p-auth-role-assign', 'auth.role.assign', 'auth.role'), + ('p-auth-role-revoke', 'auth.role.revoke', 'auth.role'), + ('p-auth-key-list', 'auth.key.list', 'auth.key'), + ('p-auth-key-create', 'auth.key.create', 'auth.key'), + ('p-auth-key-rotate', 'auth.key.rotate', 'auth.key'), + ('p-auth-key-delete', 'auth.key.delete', 'auth.key'), + ('p-auth-bootstrap-use', 'auth.bootstrap.use', 'auth.bootstrap') +ON CONFLICT (id) DO NOTHING; + +-- Default-role permission grants. Each row: (role_id, permission_id, 'global', NULL). +-- Generated programmatically from internal/domain/auth/validate.go::DefaultRoles +-- and pinned here so the schema and the code stay in lockstep. + +-- admin: every permission. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) +SELECT 'r-admin', id, 'global', NULL FROM permissions +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- operator: cert lifecycle + read across resources, no RBAC management. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES + ('r-operator', 'p-cert-read', 'global', NULL), + ('r-operator', 'p-cert-issue', 'global', NULL), + ('r-operator', 'p-cert-revoke', 'global', NULL), + ('r-operator', 'p-cert-delete', 'global', NULL), + ('r-operator', 'p-profile-read', 'global', NULL), + ('r-operator', 'p-profile-edit', 'global', NULL), + ('r-operator', 'p-issuer-read', 'global', NULL), + ('r-operator', 'p-issuer-edit', 'global', NULL), + ('r-operator', 'p-target-read', 'global', NULL), + ('r-operator', 'p-target-edit', 'global', NULL), + ('r-operator', 'p-target-delete', 'global', NULL), + ('r-operator', 'p-agent-read', 'global', NULL), + ('r-operator', 'p-agent-edit', 'global', NULL), + ('r-operator', 'p-audit-read', 'global', NULL) +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- viewer: read-only across resources. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES + ('r-viewer', 'p-cert-read', 'global', NULL), + ('r-viewer', 'p-profile-read', 'global', NULL), + ('r-viewer', 'p-issuer-read', 'global', NULL), + ('r-viewer', 'p-target-read', 'global', NULL), + ('r-viewer', 'p-agent-read', 'global', NULL), + ('r-viewer', 'p-audit-read', 'global', NULL) +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- agent: certctl-agent identity. cert.read + agent.heartbeat + agent.job.*. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES + ('r-agent', 'p-cert-read', 'global', NULL), + ('r-agent', 'p-agent-heartbeat', 'global', NULL), + ('r-agent', 'p-agent-job-poll', 'global', NULL), + ('r-agent', 'p-agent-job-complete', 'global', NULL), + ('r-agent', 'p-agent-job-report', 'global', NULL) +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- mcp: operator-equivalent minus destructive verbs. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES + ('r-mcp', 'p-cert-read', 'global', NULL), + ('r-mcp', 'p-cert-issue', 'global', NULL), + ('r-mcp', 'p-cert-revoke', 'global', NULL), + ('r-mcp', 'p-profile-read', 'global', NULL), + ('r-mcp', 'p-profile-edit', 'global', NULL), + ('r-mcp', 'p-issuer-read', 'global', NULL), + ('r-mcp', 'p-issuer-edit', 'global', NULL), + ('r-mcp', 'p-target-read', 'global', NULL), + ('r-mcp', 'p-target-edit', 'global', NULL), + ('r-mcp', 'p-agent-read', 'global', NULL), + ('r-mcp', 'p-audit-read', 'global', NULL) +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- cli: operator-equivalent + key self-management. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES + ('r-cli', 'p-cert-read', 'global', NULL), + ('r-cli', 'p-cert-issue', 'global', NULL), + ('r-cli', 'p-cert-revoke', 'global', NULL), + ('r-cli', 'p-cert-delete', 'global', NULL), + ('r-cli', 'p-profile-read', 'global', NULL), + ('r-cli', 'p-profile-edit', 'global', NULL), + ('r-cli', 'p-issuer-read', 'global', NULL), + ('r-cli', 'p-issuer-edit', 'global', NULL), + ('r-cli', 'p-target-read', 'global', NULL), + ('r-cli', 'p-target-edit', 'global', NULL), + ('r-cli', 'p-target-delete', 'global', NULL), + ('r-cli', 'p-agent-read', 'global', NULL), + ('r-cli', 'p-agent-edit', 'global', NULL), + ('r-cli', 'p-audit-read', 'global', NULL), + ('r-cli', 'p-auth-key-list', 'global', NULL), + ('r-cli', 'p-auth-key-create', 'global', NULL), + ('r-cli', 'p-auth-key-rotate', 'global', NULL) +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- auditor: read-only audit access. Phase 8 splits this from admin +-- formally; Phase 1 reserves the role and its permission set. +INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES + ('r-auditor', 'p-audit-read', 'global', NULL), + ('r-auditor', 'p-audit-export', 'global', NULL) +ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING; + +-- Demo-mode preservation: synthetic `actor-demo-anon` with admin role. +-- Bundle 1 Phase 3 will wire the auth middleware to inject this actor +-- into the request context when CERTCTL_AUTH_TYPE=none is configured. +-- The row exists unconditionally; the env-var check happens in code. +-- Reserved system actor: API rejects mutations / deletions targeting +-- this id with 409 Conflict. +INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, granted_by, tenant_id) +VALUES ( + 'ar-demo-anon-admin', + 'actor-demo-anon', + 'Anonymous', + 'r-admin', + NOW(), + 'system', + 't-default' +) +ON CONFLICT (actor_id, actor_type, role_id, tenant_id) DO NOTHING; + +COMMIT;