mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
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:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user