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.
This commit is contained in:
shankar0123
2026-05-09 16:00:08 +00:00
parent 99a012e3be
commit 19497eef87
8 changed files with 1238 additions and 2 deletions
+29 -2
View File
@@ -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"
)
+106
View File
@@ -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
+163
View File
@@ -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: <namespace>.<verb>. Read permissions use
// `<resource>.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",
},
}
+95
View File
@@ -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))
}
}
+114
View File
@@ -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
}
+442
View File
@@ -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()
}
+17
View File
@@ -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;
+272
View File
@@ -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;