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))
}
}