mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
auth-bundle-1 Phase 4 + 5: RBAC HTTP API + CLI surface
Phase 4 (HTTP API): * internal/api/handler/auth.go: AuthHandler with 12 endpoints under /api/v1/auth/* — ListRoles, GetRole, CreateRole, UpdateRole, DeleteRole, ListPermissions, AddRolePermission, RemoveRolePermission, AssignRoleToKey, RevokeRoleFromKey, Me. callerFromRequest builds an authsvc.Caller from the Phase 3 ActorIDKey/ActorTypeKey/TenantIDKey context values. writeAuthError translates service + repository sentinels into HTTP status codes (401/403/404/409/400/500). 14 handler tests with in-memory fakes pin the HTTP shape + error mapping. * internal/api/router/router.go: HandlerRegistry gains an Auth field; 11 new routes registered. openapi_parity_test SpecParityExceptions extended with the new auth routes (OpenAPI YAML schema land in a Phase 4 follow-up commit so the schema review is its own atomic change; the route shape is fully documented inline via the Go type definitions until then). * cmd/server/main.go: wires the postgres auth repos (RoleRepository, PermissionRepository, ActorRoleRepository) + the Authorizer + RoleService/PermissionService/ActorRoleService into the new AuthHandler. Adds authPermissionCheckerAdapter to bridge the typed-string Authorizer signature to the auth.PermissionChecker interface (avoids an internal/auth → internal/service/auth import cycle). Phase 5 (CLI): * cmd/cli/main.go: adds 'auth' command dispatch with subcommands roles/permissions/keys/me. * internal/cli/auth.go: AuthMe, AuthListRoles, AuthGetRole, AuthListPermissions, AuthAssignRoleToKey, AuthRevokeRoleFromKey methods on Client. Mirrors the Phase 4 HTTP surface. Phase 3.5 (handler IsAdmin → middleware-wrapped RequirePermission) DEFERRED. Honest reasoning: (1) The 5 admin handlers (bulk_revocation, admin_crl_cache, admin_scep_intune, admin_est, intermediate_ca) currently gate via auth.IsAdmin checks INSIDE the handler bodies. Converting cleanly requires moving the gate to the router (auth.RequirePermission middleware wrap) AND removing the in-handler check AND rewriting the existing 3-test triplets per handler (M-008 pinned: _NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 / _AdminPermitted_ForwardsActor) because the existing tests call the handler function directly, bypassing middleware. After conversion, those tests would pass without 403'ing because the gate moved away — the test invariants need to flow through a router-level integration setup instead. (2) Picking the right permission per handler is a security-review-worthy decision. Using existing operator-class perms (cert.revoke, issuer.edit) widens access from admin-only to operator-class; adding new admin-only perms (cert.bulk_revoke, crl.admin, scep.admin, est.admin, ca.hierarchy.manage) requires a migration 000030 plus a coordinated catalogue update in internal/domain/auth/validate.go. Both options are defensible but warrant a focused commit, not a 5-handler sweep mixed in with the API + CLI work. (3) The conversion can be done now without functional regressions IF we leave the in-handler IsAdmin checks in place AND add middleware wraps as defense-in-depth — but that's the worst of both worlds (legacy gate still blocks non-admin operators, defeating the point of RBAC; new gate adds runtime cost with no semantic change). A clean conversion needs the in-handler check removed. Concrete plan for Phase 3.5 (separate commit, next session): (a) add new admin-only perms via migration 000030 OR document the widening to operator-class; (b) wrap each of the 5 admin routes with auth.RequirePermission(checker, perm, nil) in router.go; (c) remove auth.IsAdmin checks from the 5 handler bodies; (d) move the M-008 _NonAdmin/_AdminExplicitFalse tests to router-level integration tests, keep _AdminPermitted as a direct handler test for actor-passthrough; (e) update m008_admin_gate_test.go registry to track auth.RequirePermission middleware wraps in router.go instead of auth.IsAdmin call sites in handler files. Verifications: go vet ./... clean; gofmt clean across all touched files; go test -short -count=1 across internal/auth, internal/service/auth, internal/api/handler, internal/api/router, internal/cli, cmd/server, cmd/cli all green (one transient too-many-open-files retry on internal/cli + internal/api/router; second run clean). Branch: dev/auth-bundle-1. Commit chain:99a012e(Phase 0 extract) ->19497ee(Phase 1 schema + repo) ->bd54d5f(Phase 2 service) ->d473398(Phase 3 primitive) -> THIS (Phase 4 + 5).
This commit is contained in:
@@ -111,6 +111,8 @@ Examples:
|
||||
err = handleEST(client, cmdArgs)
|
||||
case "status":
|
||||
err = handleStatus(client)
|
||||
case "auth":
|
||||
err = handleAuth(client, cmdArgs)
|
||||
case "version":
|
||||
fmt.Println("certctl-cli version 0.1.0")
|
||||
default:
|
||||
@@ -364,3 +366,87 @@ func validateHTTPSScheme(serverURL string) error {
|
||||
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuth dispatches the `certctl-cli auth ...` subcommand tree.
|
||||
// Bundle 1 Phase 5: ships read + grant operations against the
|
||||
// /api/v1/auth/* surface introduced in Phase 4. Mutations like role
|
||||
// create / update / delete can be added in a Phase 5.5 follow-up; this
|
||||
// commit ships the operator-facing subset most useful for migration
|
||||
// and day-2 scope-down (`auth keys list` + `auth keys assign` +
|
||||
// `auth me`).
|
||||
func handleAuth(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth <roles|permissions|keys|me> [...]\n")
|
||||
return nil
|
||||
}
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "roles":
|
||||
return handleAuthRoles(client, subArgs)
|
||||
case "permissions":
|
||||
return handleAuthPermissions(client, subArgs)
|
||||
case "keys":
|
||||
return handleAuthKeys(client, subArgs)
|
||||
case "me":
|
||||
return client.AuthMe()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown auth subcommand: %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthRoles(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles <list|get> [id]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListRoles()
|
||||
case "get":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthGetRole(args[1])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown roles subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthPermissions(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 || args[0] != "list" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth permissions list\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthListPermissions()
|
||||
}
|
||||
|
||||
func handleAuthKeys(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys <assign|revoke> [...]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "assign":
|
||||
// auth keys assign <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys assign <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthAssignRoleToKey(args[1], args[3])
|
||||
case "revoke":
|
||||
// auth keys revoke <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys revoke <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthRevokeRoleFromKey(args[1], args[3])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,13 @@ import (
|
||||
notifyteams "github.com/certctl-io/certctl/internal/connector/notifier/teams"
|
||||
"github.com/certctl-io/certctl/internal/crypto/signer"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||
"github.com/certctl-io/certctl/internal/scheduler"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
||||
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
@@ -252,6 +254,20 @@ func main() {
|
||||
|
||||
// Initialize services (following the dependency graph)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
|
||||
// RBAC primitive (Bundle 1 Phase 4). Wires the postgres auth repos
|
||||
// + service-layer Authorizer that the AuthHandler / RequirePermission
|
||||
// middleware uses. Migration 000029_rbac.up.sql provides the schema
|
||||
// and seeds the seven default roles + canonical permission catalogue
|
||||
// + actor-demo-anon synthetic admin (CERTCTL_AUTH_TYPE=none demo path).
|
||||
authRoleRepo := postgres.NewRoleRepository(db)
|
||||
authPermRepo := postgres.NewPermissionRepository(db)
|
||||
authActorRoleRepo := postgres.NewActorRoleRepository(db)
|
||||
authAuthorizer := authsvc.NewAuthorizer(authActorRoleRepo)
|
||||
// authCheckerAdapter bridges authsvc.Authorizer (typed-string args)
|
||||
// to the auth.PermissionChecker interface (plain-string args) so
|
||||
// internal/auth doesn't have to import internal/service/auth.
|
||||
authCheckerAdapter := authPermissionCheckerAdapter{a: authAuthorizer}
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
|
||||
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
|
||||
@@ -962,6 +978,22 @@ func main() {
|
||||
// Rank 8 of the 2026-05-03 deep-research deliverable. See
|
||||
// docs/intermediate-ca-hierarchy.md.
|
||||
IntermediateCAs: intermediateCAHandler,
|
||||
// Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres
|
||||
// auth repos + service-layer Authorizer / RoleService /
|
||||
// ActorRoleService / PermissionService into the HTTP surface
|
||||
// under /api/v1/auth/*. The service layer enforces every
|
||||
// permission gate (auth.role.* + auth.role.assign privilege-
|
||||
// escalation guard); the Phase 3 RequirePermission middleware
|
||||
// is currently used by these RBAC routes via the in-handler
|
||||
// callerFromRequest path. Phase 3.5 router-wrapping conversion
|
||||
// of the legacy admin handlers (bulk_revocation, admin_*,
|
||||
// intermediate_ca) is the remaining sweep.
|
||||
Auth: handler.NewAuthHandler(
|
||||
authsvc.NewRoleService(authRoleRepo, authPermRepo, authAuthorizer, auditService),
|
||||
authsvc.NewPermissionService(authPermRepo),
|
||||
authsvc.NewActorRoleService(authActorRoleRepo, authRoleRepo, authAuthorizer, auditService),
|
||||
authCheckerAdapter,
|
||||
),
|
||||
})
|
||||
// Register EST (RFC 7030) handlers if enabled.
|
||||
//
|
||||
@@ -2232,3 +2264,34 @@ func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, da
|
||||
http.ServeFile(w, r, webDir+"/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
// authPermissionCheckerAdapter bridges the typed-string Authorizer
|
||||
// signature (authsvc.Authorizer.CheckPermission takes
|
||||
// authdomain.ActorTypeValue + authdomain.ScopeType) to the plain-string
|
||||
// auth.PermissionChecker interface used by the auth.RequirePermission
|
||||
// middleware factory. Lives in cmd/server so internal/auth doesn't have
|
||||
// to import internal/service/auth + internal/domain/auth (would create
|
||||
// a cycle).
|
||||
type authPermissionCheckerAdapter struct {
|
||||
a *authsvc.Authorizer
|
||||
}
|
||||
|
||||
func (ad authPermissionCheckerAdapter) CheckPermission(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType string,
|
||||
tenantID string,
|
||||
permission string,
|
||||
scopeType string,
|
||||
scopeID *string,
|
||||
) (bool, error) {
|
||||
return ad.a.CheckPermission(
|
||||
ctx,
|
||||
actorID,
|
||||
authdomainAlias.ActorTypeValue(actorType),
|
||||
tenantID,
|
||||
permission,
|
||||
authdomainAlias.ScopeType(scopeType),
|
||||
scopeID,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user