mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:11:30 +00:00
19497eef87
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.
96 lines
3.5 KiB
Go
96 lines
3.5 KiB
Go
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))
|
|
}
|
|
}
|