mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:41:30 +00:00
60a589ab96
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.
117 lines
3.9 KiB
Go
117 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"github.com/certctl-io/certctl/internal/auth"
|
|
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
|
)
|
|
|
|
// fakeGranter is a tiny in-memory stand-in for the postgres ActorRoleRepository
|
|
// — enough surface area for backfillNamedKeyActorRoles to call Grant against.
|
|
type fakeGranter struct {
|
|
calls []*authdomain.ActorRole
|
|
err error
|
|
}
|
|
|
|
func (f *fakeGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error {
|
|
f.calls = append(f.calls, ar)
|
|
return f.err
|
|
}
|
|
|
|
// TestBackfillNamedKeyActorRoles_RoleMapping pins the Bundle 1 Phase 3
|
|
// closure (C2) invariant: admin-flagged named keys grant r-admin,
|
|
// non-admin keys grant r-viewer, both at TenantID t-default with
|
|
// ActorType APIKey and GrantedBy=bootstrap.
|
|
func TestBackfillNamedKeyActorRoles_RoleMapping(t *testing.T) {
|
|
repo := &fakeGranter{}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
keys := []auth.NamedAPIKey{
|
|
{Name: "alice-admin", Key: "AAA", Admin: true},
|
|
{Name: "bob-viewer", Key: "BBB", Admin: false},
|
|
{Name: "carol-admin", Key: "CCC", Admin: true},
|
|
}
|
|
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
|
|
|
if len(repo.calls) != 3 {
|
|
t.Fatalf("Grant call count = %d, want 3", len(repo.calls))
|
|
}
|
|
type want struct {
|
|
actor, role string
|
|
}
|
|
wants := []want{
|
|
{actor: "alice-admin", role: authdomain.RoleIDAdmin},
|
|
{actor: "bob-viewer", role: authdomain.RoleIDViewer},
|
|
{actor: "carol-admin", role: authdomain.RoleIDAdmin},
|
|
}
|
|
for i, w := range wants {
|
|
got := repo.calls[i]
|
|
if got.ActorID != w.actor {
|
|
t.Errorf("call[%d].ActorID = %q, want %q", i, got.ActorID, w.actor)
|
|
}
|
|
if got.RoleID != w.role {
|
|
t.Errorf("call[%d].RoleID = %q, want %q", i, got.RoleID, w.role)
|
|
}
|
|
if got.TenantID != authdomain.DefaultTenantID {
|
|
t.Errorf("call[%d].TenantID = %q, want %q", i, got.TenantID, authdomain.DefaultTenantID)
|
|
}
|
|
if string(got.ActorType) != "APIKey" {
|
|
t.Errorf("call[%d].ActorType = %q, want APIKey", i, got.ActorType)
|
|
}
|
|
if got.GrantedBy != "bootstrap" {
|
|
t.Errorf("call[%d].GrantedBy = %q, want bootstrap", i, got.GrantedBy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp confirms the boot path
|
|
// is safe when no named keys are configured (typical CERTCTL_AUTH_TYPE=
|
|
// none deploy). No Grant calls; no panic.
|
|
func TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp(t *testing.T) {
|
|
repo := &fakeGranter{}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
backfillNamedKeyActorRoles(context.Background(), repo, nil, logger)
|
|
if len(repo.calls) != 0 {
|
|
t.Errorf("Grant called %d times for empty keys, want 0", len(repo.calls))
|
|
}
|
|
}
|
|
|
|
// TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal confirms the
|
|
// closure invariant that a Grant failure logs a warning and proceeds
|
|
// rather than crashing the server during boot. Subsequent keys still
|
|
// get processed.
|
|
func TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal(t *testing.T) {
|
|
repo := &fakeGranter{err: errors.New("simulated DB error")}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
keys := []auth.NamedAPIKey{
|
|
{Name: "alice", Key: "A", Admin: true},
|
|
{Name: "bob", Key: "B", Admin: false},
|
|
}
|
|
// Should not panic.
|
|
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
|
|
|
if len(repo.calls) != 2 {
|
|
t.Errorf("Grant calls = %d, want 2 (every key processed even when prior Grant errored)", len(repo.calls))
|
|
}
|
|
}
|
|
|
|
// TestBackfillNamedKeyActorRoles_NilLoggerIsSafe pins that callers
|
|
// passing nil for the logger don't NPE the goroutine. Belt-and-braces
|
|
// for tests + future call sites that may not have a logger plumbed.
|
|
func TestBackfillNamedKeyActorRoles_NilLoggerIsSafe(t *testing.T) {
|
|
repo := &fakeGranter{err: errors.New("simulated")}
|
|
keys := []auth.NamedAPIKey{
|
|
{Name: "alice", Key: "A", Admin: true},
|
|
}
|
|
backfillNamedKeyActorRoles(context.Background(), repo, keys, nil)
|
|
if len(repo.calls) != 1 {
|
|
t.Errorf("Grant calls = %d, want 1", len(repo.calls))
|
|
}
|
|
}
|