mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
auth-bundle-1 Phase 0-5 closure: demo-mode wire, named-key backfill, AuthCheck enrichment, OpenAPI schema, intermediate-ca comment refresh
Closes the 5 gaps the post-Phase-5 audit flagged on dev/auth-bundle-1.
C1: cmd/server/main.go now selects auth.NewDemoModeAuth() when
CERTCTL_AUTH_TYPE=none and falls back to auth.NewAuthWithNamedKeys
otherwise. Pre-closure, the no-op pass-through that
NewAuthWithNamedKeys returns for empty keys would have left
ActorIDKey / ActorTypeKey / TenantIDKey unpopulated and 401'd
every Phase-3.5 rbacGate-wrapped admin route + every Phase-4
RBAC handler in demo deployments. NewDemoModeAuth injects the
synthetic 'actor-demo-anon' actor seeded by migration 000029,
which holds r-admin at global scope.
C2: backfillNamedKeyActorRoles startup hook (cmd/server/auth_backfill.go)
iterates CERTCTL_API_KEYS_NAMED entries (and legacy
CERTCTL_AUTH_SECRET synthesized fallbacks) and grants r-admin
or r-viewer to each via authActorRoleRepo.Grant before the
HTTP server starts accepting requests. Idempotent via
ON CONFLICT DO NOTHING in the repo. Failures log a warning but
are non-fatal — the server still starts and the operator can
fix grants via /v1/auth/keys. Helper extracted from main.go so
the role-mapping invariant is pinned by 4 focused unit tests
(admin->r-admin, non-admin->r-viewer, empty no-op,
grant-error non-fatal, nil-logger safe).
M1: HealthHandler.AuthCheck now returns actor_id, actor_type,
tenant_id, roles, effective_permissions, and admin_via_role
when the optional AuthCheckResolver is wired (production path:
authCheckResolverAdapter wraps the postgres ActorRoleRepository
in main.go). Nil resolver preserves the legacy {status, user,
admin} contract for back-compat with pre-Bundle-1 GUIs and
test fixtures. Adds 2 regression tests + 1 fake resolver shim.
M2: refreshes the stale 'Admin gate: every method calls
auth.IsAdmin first' comment on IntermediateCAHandler — the gate
moved to router.go::rbacGate via auth.RequirePermission
middleware in Phase 3.5; the new comment block points readers
there.
M4: 11 RBAC routes (auth/me, auth/permissions, 5 role lifecycle,
2 role-permission grant/revoke, 2 actor-role grant/revoke) added
to api/openapi.yaml under the [Auth] tag with operationIds and
shared AuthRole / AuthRolePermission schemas. AuthCheck path
extended with the Bundle-1 enrichment fields. The 11 entries
removed from openapi_parity_test.go::SpecParityExceptions.
Tests: go vet + staticcheck + go test -short -count=1 green
across cmd/server/, internal/auth/, internal/api/router/, and
internal/api/handler/. New tests: 4 backfill unit tests,
2 AuthCheck M1 enrichment tests, 1 demo-mode + rbacGate chain
integration test (TestRBACGate_DemoModeChainReachesHandler).
Branch SECURITY.md (cowork/auth-bundle-1-SECURITY.md, not part
of this commit) captures the full posture of dev/auth-bundle-1
as of this closure for the operator's pre-merge review.
This commit is contained in:
@@ -7,8 +7,33 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// AuthCheckResolver is the optional dependency HealthHandler uses to enrich
|
||||
// the /v1/auth/check response with the caller's standing roles and
|
||||
// effective permission set. The auth handler's /v1/auth/me endpoint
|
||||
// returns the same shape; we duplicate it here so the GUI can render the
|
||||
// auth gate from a single round-trip on app boot. main.go wires this
|
||||
// from the same authsvc.ActorRoleService used by AuthHandler; tests pass
|
||||
// nil and AuthCheck degrades to the legacy minimal payload.
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): pre-closure, /v1/auth/check returned
|
||||
// only {status, user, admin}. The GUI had to second-fetch /v1/auth/me to
|
||||
// know which buttons to render — and Me is gated by the rbacGate on
|
||||
// auth.role.list which the GUI's pre-render path may not yet hold (chicken-
|
||||
// and-egg with the role-list affordance). Folding the same payload into
|
||||
// AuthCheck keeps the GUI's boot path single-shot.
|
||||
type AuthCheckResolver interface {
|
||||
// ListRoles returns the actor's standing role grants.
|
||||
ListRoles(ctx context.Context, actorID string, actorType domain.ActorType, tenantID string) ([]*authdomain.ActorRole, error)
|
||||
// EffectivePermissions returns the deduplicated (perm, scope) triples
|
||||
// the actor holds across all of its roles.
|
||||
EffectivePermissions(ctx context.Context, actorID string, actorType domain.ActorType, tenantID string) ([]repository.EffectivePermission, error)
|
||||
}
|
||||
|
||||
// HealthHandler handles health and readiness check endpoints.
|
||||
//
|
||||
// Bundle-5 / Audit H-006 / CWE-754 (Improper Check for Unusual or
|
||||
@@ -45,6 +70,13 @@ type HealthHandler struct {
|
||||
// ReadyProbeTimeout is the per-probe ceiling for the DB ping. Defaults
|
||||
// to 2s when zero. Exposed so tests can shorten it.
|
||||
ReadyProbeTimeout time.Duration
|
||||
|
||||
// AuthCheck (M1) — optional. When set, AuthCheck includes the caller's
|
||||
// standing roles + effective permissions in the response so the GUI
|
||||
// can gate affordances from a single fetch. Nil resolver degrades to
|
||||
// the legacy {status, user, admin} payload (preserves test fixtures
|
||||
// and the no-db deploy path).
|
||||
Resolver AuthCheckResolver
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new HealthHandler.
|
||||
@@ -53,6 +85,10 @@ type HealthHandler struct {
|
||||
// Ready returns 200 with {"db":"not_configured"} — preserves backwards
|
||||
// compatibility for the call sites that haven't wired the dependency yet.
|
||||
// Production main.go always passes a non-nil pool.
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): the resolver is wired separately via
|
||||
// HealthHandler.Resolver after construction so existing call sites
|
||||
// (legacy tests, no-db deploys) keep compiling without churn.
|
||||
func NewHealthHandler(authType string, db *sql.DB) HealthHandler {
|
||||
return HealthHandler{
|
||||
AuthType: authType,
|
||||
@@ -145,15 +181,69 @@ func (h HealthHandler) AuthInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// that would otherwise 403 at the server. This is a hint for UX only —
|
||||
// authorization remains enforced at the handler layer (bulk_revocation.go).
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): when HealthHandler.Resolver is wired,
|
||||
// the response is enriched with the caller's standing roles and effective
|
||||
// permissions. This mirrors the /v1/auth/me payload but lives on /auth/check
|
||||
// so the GUI can gate affordance rendering with a single fetch on app
|
||||
// boot. Resolver lookups are best-effort: failures fall back to the
|
||||
// legacy minimal payload rather than 500-ing the GUI's auth probe.
|
||||
//
|
||||
// The auth middleware runs before this handler, so reaching here means auth
|
||||
// passed. `user` falls back to an empty string when auth is disabled
|
||||
// (CERTCTL_AUTH_TYPE=none).
|
||||
// GET /api/v1/auth/check
|
||||
func (h HealthHandler) AuthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
response := map[string]interface{}{
|
||||
"status": "authenticated",
|
||||
"user": auth.GetUser(r.Context()),
|
||||
"admin": auth.IsAdmin(r.Context()),
|
||||
"user": auth.GetUser(ctx),
|
||||
"admin": auth.IsAdmin(ctx),
|
||||
}
|
||||
|
||||
if h.Resolver != nil {
|
||||
actorID, _ := ctx.Value(auth.ActorIDKey{}).(string)
|
||||
actorType, _ := ctx.Value(auth.ActorTypeKey{}).(string)
|
||||
tenantID, _ := ctx.Value(auth.TenantIDKey{}).(string)
|
||||
if tenantID == "" {
|
||||
tenantID = authdomain.DefaultTenantID
|
||||
}
|
||||
if actorID != "" && actorType != "" {
|
||||
at := domain.ActorType(actorType)
|
||||
roles, rerr := h.Resolver.ListRoles(ctx, actorID, at, tenantID)
|
||||
perms, perr := h.Resolver.EffectivePermissions(ctx, actorID, at, tenantID)
|
||||
if rerr == nil && perr == nil {
|
||||
roleIDs := make([]string, 0, len(roles))
|
||||
hasAdmin := false
|
||||
for _, role := range roles {
|
||||
roleIDs = append(roleIDs, role.RoleID)
|
||||
if role.RoleID == authdomain.RoleIDAdmin {
|
||||
hasAdmin = true
|
||||
}
|
||||
}
|
||||
permPayload := make([]map[string]interface{}, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
entry := map[string]interface{}{
|
||||
"permission": p.PermissionName,
|
||||
"scope_type": string(p.ScopeType),
|
||||
}
|
||||
if p.ScopeID != nil {
|
||||
entry["scope_id"] = *p.ScopeID
|
||||
}
|
||||
permPayload = append(permPayload, entry)
|
||||
}
|
||||
response["actor_id"] = actorID
|
||||
response["actor_type"] = actorType
|
||||
response["tenant_id"] = tenantID
|
||||
response["roles"] = roleIDs
|
||||
response["effective_permissions"] = permPayload
|
||||
// Authoritative admin signal: the standing-roles list. The
|
||||
// legacy `admin` boolean above is preserved for back-compat
|
||||
// (in-handler IsAdmin for non-rbacGate routes), but the
|
||||
// rbacGate-gated routes now key off effective_permissions.
|
||||
response["admin_via_role"] = hasAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
|
||||
)
|
||||
|
||||
@@ -338,6 +341,120 @@ func TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAuthCheckResolver is a tiny in-memory stand-in for the postgres
|
||||
// ActorRoleRepository so the M1 enrichment can be tested without a DB.
|
||||
type fakeAuthCheckResolver struct {
|
||||
roles []*authdomain.ActorRole
|
||||
perms []repository.EffectivePermission
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeAuthCheckResolver) ListRoles(_ context.Context, _ string, _ domain.ActorType, _ string) ([]*authdomain.ActorRole, error) {
|
||||
return f.roles, f.err
|
||||
}
|
||||
func (f fakeAuthCheckResolver) EffectivePermissions(_ context.Context, _ string, _ domain.ActorType, _ string) ([]repository.EffectivePermission, error) {
|
||||
return f.perms, f.err
|
||||
}
|
||||
|
||||
// TestAuthCheck_M1_ResolverEnrichesResponseWithRolesAndPerms is the
|
||||
// Bundle 1 Phase 3 closure (M1) regression: when HealthHandler.Resolver
|
||||
// is wired, the response includes actor_id / actor_type / tenant_id /
|
||||
// roles / effective_permissions / admin_via_role. The legacy `admin`
|
||||
// boolean is preserved for back-compat with pre-Bundle-1 GUIs.
|
||||
func TestAuthCheck_M1_ResolverEnrichesResponseWithRolesAndPerms(t *testing.T) {
|
||||
handler := NewHealthHandler("api-key", nil)
|
||||
scopeID := "profile-prod"
|
||||
handler.Resolver = fakeAuthCheckResolver{
|
||||
roles: []*authdomain.ActorRole{
|
||||
{ActorID: "alice", RoleID: authdomain.RoleIDAdmin, TenantID: authdomain.DefaultTenantID},
|
||||
{ActorID: "alice", RoleID: authdomain.RoleIDOperator, TenantID: authdomain.DefaultTenantID},
|
||||
},
|
||||
perms: []repository.EffectivePermission{
|
||||
{PermissionName: "cert.bulk_revoke", ScopeType: authdomain.ScopeTypeGlobal},
|
||||
{PermissionName: "cert.issue", ScopeType: authdomain.ScopeTypeProfile, ScopeID: &scopeID},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, auth.ActorIDKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey")
|
||||
ctx = context.WithValue(ctx, auth.TenantIDKey{}, "t-default")
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.AuthCheck(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if result["actor_id"] != "alice" {
|
||||
t.Errorf("actor_id = %v, want alice", result["actor_id"])
|
||||
}
|
||||
if result["actor_type"] != "APIKey" {
|
||||
t.Errorf("actor_type = %v, want APIKey", result["actor_type"])
|
||||
}
|
||||
if result["tenant_id"] != "t-default" {
|
||||
t.Errorf("tenant_id = %v, want t-default", result["tenant_id"])
|
||||
}
|
||||
if result["admin_via_role"] != true {
|
||||
t.Errorf("admin_via_role = %v, want true (alice holds r-admin)", result["admin_via_role"])
|
||||
}
|
||||
roles, ok := result["roles"].([]any)
|
||||
if !ok || len(roles) != 2 {
|
||||
t.Fatalf("roles = %v, want 2-element slice", result["roles"])
|
||||
}
|
||||
perms, ok := result["effective_permissions"].([]any)
|
||||
if !ok || len(perms) != 2 {
|
||||
t.Fatalf("effective_permissions = %v, want 2-element slice", result["effective_permissions"])
|
||||
}
|
||||
first := perms[0].(map[string]any)
|
||||
if first["permission"] != "cert.bulk_revoke" || first["scope_type"] != "global" {
|
||||
t.Errorf("perm[0] = %v, want cert.bulk_revoke/global", first)
|
||||
}
|
||||
second := perms[1].(map[string]any)
|
||||
if second["permission"] != "cert.issue" || second["scope_type"] != "profile" || second["scope_id"] != "profile-prod" {
|
||||
t.Errorf("perm[1] = %v, want cert.issue/profile/profile-prod", second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthCheck_M1_NilResolverPreservesLegacyShape pins backwards
|
||||
// compatibility: when no resolver is wired, the response keeps the
|
||||
// original {status, user, admin} contract that pre-Bundle-1 GUIs key
|
||||
// off. New keys (actor_id, roles, ...) must be absent.
|
||||
func TestAuthCheck_M1_NilResolverPreservesLegacyShape(t *testing.T) {
|
||||
handler := NewHealthHandler("api-key", nil) // Resolver left nil
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, auth.ActorIDKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey")
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.AuthCheck(w, req)
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, k := range []string{"actor_id", "actor_type", "tenant_id", "roles", "effective_permissions", "admin_via_role"} {
|
||||
if _, present := result[k]; present {
|
||||
t.Errorf("%s should be absent in legacy (nil resolver) response, got %v", k, result[k])
|
||||
}
|
||||
}
|
||||
if result["admin"] != true || result["user"] != "alice" {
|
||||
t.Errorf("legacy fields not preserved: admin=%v user=%v", result["admin"], result["user"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bundle-5 / H-006: /ready DB-probe regression coverage ---
|
||||
|
||||
// TestReady_DBPingSuccess_Returns200WithReachable confirms that when the
|
||||
|
||||
@@ -37,12 +37,15 @@ type IntermediateCAServicer interface {
|
||||
// All routes are pinned at /api/v1/issuers/{id}/intermediates and
|
||||
// /api/v1/intermediates/{id}.
|
||||
//
|
||||
// Admin gate: every method calls auth.IsAdmin first and surfaces
|
||||
// HTTP 403 for non-admin Bearer callers (M-003 admin-gating pattern,
|
||||
// matches AdminCRLCacheHandler / AdminESTHandler / AdminSCEPIntuneHandler).
|
||||
// CA hierarchy management is a high-blast-radius surface — adding a
|
||||
// child CA mints a new sub-CA cert that becomes a trust root for every
|
||||
// downstream leaf. Operators expect this gated behind admin role.
|
||||
// Bundle 1 Phase 3.5: the admin gate moved from in-handler auth.IsAdmin
|
||||
// checks to router-level auth.RequirePermission middleware (rbacGate
|
||||
// wraps the handler with the ca.hierarchy.manage permission gate before
|
||||
// the handler body runs — non-admin Bearer callers get 403 from the
|
||||
// middleware layer instead of from each handler method). CA hierarchy
|
||||
// management is a high-blast-radius surface — adding a child CA mints a
|
||||
// new sub-CA cert that becomes a trust root for every downstream leaf.
|
||||
// The router gate guarantees the only callers reaching this handler
|
||||
// hold the admin role at global scope.
|
||||
type IntermediateCAHandler struct {
|
||||
svc IntermediateCAServicer
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user