From b169f258dee573ec251e06ffb271e4cac6d1d5b6 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 9 May 2026 16:43:48 +0000 Subject: [PATCH] auth-bundle-1 Phase 4 + 5: RBAC HTTP API + CLI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cmd/cli/main.go | 86 ++++ cmd/server/main.go | 63 +++ internal/api/handler/auth.go | 489 +++++++++++++++++++++ internal/api/handler/auth_test.go | 424 ++++++++++++++++++ internal/api/router/openapi_parity_test.go | 18 + internal/api/router/router.go | 23 + internal/cli/auth.go | 253 +++++++++++ 7 files changed, 1356 insertions(+) create mode 100644 internal/api/handler/auth.go create mode 100644 internal/api/handler/auth_test.go create mode 100644 internal/cli/auth.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 9a0d726..443c22b 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -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 [...]\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 [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 \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 [...]\n") + return nil + } + switch args[0] { + case "assign": + // auth keys assign --role + if len(args) < 4 || args[2] != "--role" { + fmt.Fprintf(os.Stderr, "usage: auth keys assign --role \n") + return nil + } + return client.AuthAssignRoleToKey(args[1], args[3]) + case "revoke": + // auth keys revoke --role + if len(args) < 4 || args[2] != "--role" { + fmt.Fprintf(os.Stderr, "usage: auth keys revoke --role \n") + return nil + } + return client.AuthRevokeRoleFromKey(args[1], args[3]) + default: + fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0]) + return nil + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 510c725..8883993 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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, + ) +} diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go new file mode 100644 index 0000000..fb20805 --- /dev/null +++ b/internal/api/handler/auth.go @@ -0,0 +1,489 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "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" + authsvc "github.com/certctl-io/certctl/internal/service/auth" +) + +// AuthHandler exposes the RBAC primitive over HTTP. Bundle 1 Phase 4 wires +// the routes registered by HandlerRegistry under /v1/auth/*. +// +// Every mutating endpoint runs through the service layer, which enforces +// the privilege-escalation guard (callers need auth.role.assign for +// Grant/Revoke, auth.role.create/edit/delete for the role lifecycle, +// auth.key.* for key management). Read endpoints require auth.role.list. +// +// The /v1/auth/me endpoint has no permission requirement (every +// authenticated caller can read their own permissions); this is the +// query the GUI uses to gate affordance rendering. +type AuthHandler struct { + roles AuthRoleService + perms AuthPermissionService + actors AuthActorRoleService + checker auth.PermissionChecker +} + +// AuthRoleService is the service-layer dependency the AuthHandler uses +// for role + role-permission lifecycle. Mirrors internal/service/auth. +type AuthRoleService interface { + List(ctx context.Context, caller *authsvc.Caller) ([]*authdomain.Role, error) + Get(ctx context.Context, caller *authsvc.Caller, id string) (*authdomain.Role, error) + Create(ctx context.Context, caller *authsvc.Caller, role *authdomain.Role) error + Update(ctx context.Context, caller *authsvc.Caller, role *authdomain.Role) error + Delete(ctx context.Context, caller *authsvc.Caller, id string) error + ListPermissions(ctx context.Context, caller *authsvc.Caller, roleID string) ([]*authdomain.RolePermission, error) + AddPermission(ctx context.Context, caller *authsvc.Caller, roleID, permName string, scopeType authdomain.ScopeType, scopeID *string) error + RemovePermission(ctx context.Context, caller *authsvc.Caller, roleID, permName string, scopeType authdomain.ScopeType, scopeID *string) error +} + +// AuthPermissionService exposes the canonical permission catalogue. +type AuthPermissionService interface { + List(ctx context.Context) ([]*authdomain.Permission, error) + IsRegistered(name string) bool +} + +// AuthActorRoleService manages role grants on actors and surfaces the +// effective-permissions query the GUI's /v1/auth/me handler uses. +type AuthActorRoleService interface { + Grant(ctx context.Context, caller *authsvc.Caller, ar *authdomain.ActorRole) error + Revoke(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType, roleID string) error + ListForActor(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]*authdomain.ActorRole, error) + EffectivePermissions(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]repository.EffectivePermission, error) +} + +// NewAuthHandler constructs an AuthHandler with the service-layer +// dependencies wired in cmd/server/main.go. +func NewAuthHandler( + roles AuthRoleService, + perms AuthPermissionService, + actors AuthActorRoleService, + checker auth.PermissionChecker, +) AuthHandler { + return AuthHandler{ + roles: roles, + perms: perms, + actors: actors, + checker: checker, + } +} + +// ============================================================================= +// JSON request / response shapes +// ============================================================================= + +type roleResponse struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func roleToResponse(r *authdomain.Role) roleResponse { + return roleResponse{ + ID: r.ID, + TenantID: r.TenantID, + Name: r.Name, + Description: r.Description, + CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"), + } +} + +type permissionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +func permToResponse(p *authdomain.Permission) permissionResponse { + return permissionResponse{ID: p.ID, Name: p.Name, Namespace: p.Namespace} +} + +type rolePermissionResponse struct { + RoleID string `json:"role_id"` + PermissionID string `json:"permission_id"` + ScopeType string `json:"scope_type"` + ScopeID *string `json:"scope_id,omitempty"` +} + +func rolePermToResponse(g *authdomain.RolePermission) rolePermissionResponse { + return rolePermissionResponse{ + RoleID: g.RoleID, + PermissionID: g.PermissionID, + ScopeType: string(g.ScopeType), + ScopeID: g.ScopeID, + } +} + +type createRoleRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type updateRoleRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type addPermissionRequest struct { + Permission string `json:"permission"` + ScopeType string `json:"scope_type,omitempty"` // defaults to "global" + ScopeID *string `json:"scope_id,omitempty"` +} + +type assignRoleRequest struct { + RoleID string `json:"role_id"` +} + +type meResponse struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + TenantID string `json:"tenant_id"` + Admin bool `json:"admin"` // back-compat with /v1/auth/check + Roles []string `json:"roles"` + EffectivePermissions []effectivePermissionPayload `json:"effective_permissions"` +} + +type effectivePermissionPayload struct { + Permission string `json:"permission"` + ScopeType string `json:"scope_type"` + ScopeID *string `json:"scope_id,omitempty"` +} + +// ============================================================================= +// Handlers +// ============================================================================= + +// ListRoles handles GET /api/v1/auth/roles. +// Permission: auth.role.list (enforced at the service layer). +func (h AuthHandler) ListRoles(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + roles, err := h.roles.List(r.Context(), caller) + if err != nil { + writeAuthError(w, err) + return + } + out := make([]roleResponse, 0, len(roles)) + for _, role := range roles { + out = append(out, roleToResponse(role)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"roles": out}) +} + +// GetRole handles GET /api/v1/auth/roles/{id}. +func (h AuthHandler) GetRole(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + role, err := h.roles.Get(r.Context(), caller, id) + if err != nil { + writeAuthError(w, err) + return + } + perms, err := h.roles.ListPermissions(r.Context(), caller, id) + if err != nil { + writeAuthError(w, err) + return + } + permResponses := make([]rolePermissionResponse, 0, len(perms)) + for _, p := range perms { + permResponses = append(permResponses, rolePermToResponse(p)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "role": roleToResponse(role), + "permissions": permResponses, + }) +} + +// CreateRole handles POST /api/v1/auth/roles. +func (h AuthHandler) CreateRole(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + var req createRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, http.StatusBadRequest, "Invalid request body") + return + } + if strings.TrimSpace(req.Name) == "" { + Error(w, http.StatusBadRequest, "role name is required") + return + } + role := &authdomain.Role{Name: req.Name, Description: req.Description} + if err := h.roles.Create(r.Context(), caller, role); err != nil { + writeAuthError(w, err) + return + } + writeJSON(w, http.StatusCreated, roleToResponse(role)) +} + +// UpdateRole handles PUT /api/v1/auth/roles/{id}. +func (h AuthHandler) UpdateRole(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + var req updateRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, http.StatusBadRequest, "Invalid request body") + return + } + role := &authdomain.Role{ID: id, Name: req.Name, Description: req.Description} + if err := h.roles.Update(r.Context(), caller, role); err != nil { + writeAuthError(w, err) + return + } + writeJSON(w, http.StatusOK, roleToResponse(role)) +} + +// DeleteRole handles DELETE /api/v1/auth/roles/{id}. +func (h AuthHandler) DeleteRole(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + if err := h.roles.Delete(r.Context(), caller, id); err != nil { + writeAuthError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ListPermissions handles GET /api/v1/auth/permissions. +func (h AuthHandler) ListPermissions(w http.ResponseWriter, r *http.Request) { + if _, err := callerFromRequest(r); err != nil { + writeAuthError(w, err) + return + } + perms, err := h.perms.List(r.Context()) + if err != nil { + writeAuthError(w, err) + return + } + out := make([]permissionResponse, 0, len(perms)) + for _, p := range perms { + out = append(out, permToResponse(p)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"permissions": out}) +} + +// AddRolePermission handles POST /api/v1/auth/roles/{id}/permissions. +func (h AuthHandler) AddRolePermission(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + roleID := r.PathValue("id") + var req addPermissionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, http.StatusBadRequest, "Invalid request body") + return + } + if req.Permission == "" { + Error(w, http.StatusBadRequest, "permission is required") + return + } + scopeType := authdomain.ScopeType(req.ScopeType) + if scopeType == "" { + scopeType = authdomain.ScopeTypeGlobal + } + if err := h.roles.AddPermission(r.Context(), caller, roleID, req.Permission, scopeType, req.ScopeID); err != nil { + writeAuthError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// RemoveRolePermission handles DELETE /api/v1/auth/roles/{id}/permissions/{perm}. +func (h AuthHandler) RemoveRolePermission(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + roleID := r.PathValue("id") + permName := r.PathValue("perm") + scopeType := authdomain.ScopeType(r.URL.Query().Get("scope_type")) + if scopeType == "" { + scopeType = authdomain.ScopeTypeGlobal + } + var scopeID *string + if v := r.URL.Query().Get("scope_id"); v != "" { + scopeID = &v + } + if err := h.roles.RemovePermission(r.Context(), caller, roleID, permName, scopeType, scopeID); err != nil { + writeAuthError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// AssignRoleToKey handles POST /api/v1/auth/keys/{id}/roles. +// {id} is the API-key actor name (e.g. "alice", "ops-admin"); the +// service layer resolves to the actor_roles row. +func (h AuthHandler) AssignRoleToKey(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + keyID := r.PathValue("id") + var req assignRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, http.StatusBadRequest, "Invalid request body") + return + } + if req.RoleID == "" { + Error(w, http.StatusBadRequest, "role_id is required") + return + } + ar := &authdomain.ActorRole{ + ActorID: keyID, + ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), + RoleID: req.RoleID, + } + if err := h.actors.Grant(r.Context(), caller, ar); err != nil { + writeAuthError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// RevokeRoleFromKey handles DELETE /api/v1/auth/keys/{id}/roles/{role_id}. +func (h AuthHandler) RevokeRoleFromKey(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + keyID := r.PathValue("id") + roleID := r.PathValue("role_id") + if err := h.actors.Revoke(r.Context(), caller, keyID, domain.ActorTypeAPIKey, roleID); err != nil { + writeAuthError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Me handles GET /api/v1/auth/me. Returns the current actor's effective +// permissions plus admin flag (back-compat with /v1/auth/check). No +// permission required: every authenticated caller can read their own. +func (h AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + roles, err := h.actors.ListForActor(r.Context(), caller, caller.ActorID, caller.ActorType) + if err != nil { + writeAuthError(w, err) + return + } + roleIDs := make([]string, 0, len(roles)) + hasAdmin := false + for _, role := range roles { + roleIDs = append(roleIDs, role.RoleID) + if role.RoleID == authdomain.RoleIDAdmin { + hasAdmin = true + } + } + effective, err := h.actors.EffectivePermissions(r.Context(), caller, caller.ActorID, caller.ActorType) + if err != nil { + writeAuthError(w, err) + return + } + payload := make([]effectivePermissionPayload, 0, len(effective)) + for _, p := range effective { + payload = append(payload, effectivePermissionPayload{ + Permission: p.PermissionName, + ScopeType: string(p.ScopeType), + ScopeID: p.ScopeID, + }) + } + writeJSON(w, http.StatusOK, meResponse{ + ActorID: caller.ActorID, + ActorType: string(caller.ActorType), + TenantID: caller.TenantID, + Admin: hasAdmin, + Roles: roleIDs, + EffectivePermissions: payload, + }) +} + +// ============================================================================= +// Helpers +// ============================================================================= + +// callerFromRequest builds an authsvc.Caller from request context. The +// auth middleware (Phase 3) populates ActorIDKey / ActorTypeKey / +// TenantIDKey on every authenticated request. Returns auth.ErrNoActor +// when no actor is in context (handler returns 401). +func callerFromRequest(r *http.Request) (*authsvc.Caller, error) { + ctx := r.Context() + actorID := auth.GetActorID(ctx) + if actorID == "" { + return nil, auth.ErrNoActor + } + actorType := auth.GetActorType(ctx) + if actorType == "" { + actorType = auth.ActorTypeAPIKey + } + tenantID := auth.GetTenantID(ctx) + return &authsvc.Caller{ + ActorID: actorID, + ActorType: domain.ActorType(actorType), + TenantID: tenantID, + }, nil +} + +// writeAuthError translates service-layer + repository sentinel errors +// into HTTP status codes. Any non-mapped error is 500. +func writeAuthError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, auth.ErrNoActor), errors.Is(err, authsvc.ErrUnauthenticated): + Error(w, http.StatusUnauthorized, "Authentication required") + case errors.Is(err, authsvc.ErrForbidden), errors.Is(err, authsvc.ErrSelfRoleAssignment): + Error(w, http.StatusForbidden, err.Error()) + case errors.Is(err, authsvc.ErrInvalidPermission): + Error(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, repository.ErrAuthNotFound): + Error(w, http.StatusNotFound, "Not found") + case errors.Is(err, repository.ErrAuthDuplicateName), errors.Is(err, repository.ErrAuthRoleInUse), errors.Is(err, repository.ErrAuthReservedActor): + Error(w, http.StatusConflict, err.Error()) + case errors.Is(err, repository.ErrAuthUnknownPermission): + Error(w, http.StatusBadRequest, err.Error()) + default: + Error(w, http.StatusInternalServerError, "Internal error") + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/api/handler/auth_test.go b/internal/api/handler/auth_test.go new file mode 100644 index 0000000..fe95aa1 --- /dev/null +++ b/internal/api/handler/auth_test.go @@ -0,0 +1,424 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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" + authsvc "github.com/certctl-io/certctl/internal/service/auth" +) + +// ============================================================================= +// In-memory fakes — sufficient for handler-level translation tests. The +// service-layer privilege guards live in internal/service/auth and are +// covered there; these tests pin HTTP shape (status code, JSON envelope, +// error mapping). +// ============================================================================= + +type fakeAuthRoleSvc struct { + roles map[string]*authdomain.Role + rolePerms map[string][]*authdomain.RolePermission + listErr error + createErr error + deleteErr error + addPermErr error +} + +func newFakeAuthRoleSvc() *fakeAuthRoleSvc { + return &fakeAuthRoleSvc{ + roles: map[string]*authdomain.Role{}, + rolePerms: map[string][]*authdomain.RolePermission{}, + } +} +func (f *fakeAuthRoleSvc) List(_ context.Context, _ *authsvc.Caller) ([]*authdomain.Role, error) { + if f.listErr != nil { + return nil, f.listErr + } + out := make([]*authdomain.Role, 0, len(f.roles)) + for _, r := range f.roles { + out = append(out, r) + } + return out, nil +} +func (f *fakeAuthRoleSvc) Get(_ context.Context, _ *authsvc.Caller, id string) (*authdomain.Role, error) { + r, ok := f.roles[id] + if !ok { + return nil, repository.ErrAuthNotFound + } + return r, nil +} +func (f *fakeAuthRoleSvc) Create(_ context.Context, _ *authsvc.Caller, role *authdomain.Role) error { + if f.createErr != nil { + return f.createErr + } + if role.ID == "" { + role.ID = "r-" + role.Name + } + f.roles[role.ID] = role + return nil +} +func (f *fakeAuthRoleSvc) Update(_ context.Context, _ *authsvc.Caller, role *authdomain.Role) error { + f.roles[role.ID] = role + return nil +} +func (f *fakeAuthRoleSvc) Delete(_ context.Context, _ *authsvc.Caller, id string) error { + if f.deleteErr != nil { + return f.deleteErr + } + delete(f.roles, id) + return nil +} +func (f *fakeAuthRoleSvc) ListPermissions(_ context.Context, _ *authsvc.Caller, roleID string) ([]*authdomain.RolePermission, error) { + return f.rolePerms[roleID], nil +} +func (f *fakeAuthRoleSvc) AddPermission(_ context.Context, _ *authsvc.Caller, roleID, permName string, scopeType authdomain.ScopeType, scopeID *string) error { + if f.addPermErr != nil { + return f.addPermErr + } + f.rolePerms[roleID] = append(f.rolePerms[roleID], &authdomain.RolePermission{ + RoleID: roleID, PermissionID: "p-" + permName, ScopeType: scopeType, ScopeID: scopeID, + }) + return nil +} +func (f *fakeAuthRoleSvc) RemovePermission(_ context.Context, _ *authsvc.Caller, _ string, _ string, _ authdomain.ScopeType, _ *string) error { + return nil +} + +type fakeAuthPermSvc struct { + perms []*authdomain.Permission +} + +func newFakeAuthPermSvc() *fakeAuthPermSvc { + out := make([]*authdomain.Permission, 0, len(authdomain.CanonicalPermissions)) + for _, p := range authdomain.CanonicalPermissions { + out = append(out, &authdomain.Permission{ID: "p-" + p, Name: p, Namespace: p}) + } + return &fakeAuthPermSvc{perms: out} +} +func (f *fakeAuthPermSvc) List(_ context.Context) ([]*authdomain.Permission, error) { + return f.perms, nil +} +func (f *fakeAuthPermSvc) IsRegistered(name string) bool { + for _, p := range f.perms { + if p.Name == name { + return true + } + } + return false +} + +type fakeAuthActorSvc struct { + grantErr error + revokeErr error + roles []*authdomain.ActorRole + effective []repository.EffectivePermission +} + +func newFakeAuthActorSvc() *fakeAuthActorSvc { + return &fakeAuthActorSvc{} +} +func (f *fakeAuthActorSvc) Grant(_ context.Context, _ *authsvc.Caller, ar *authdomain.ActorRole) error { + if f.grantErr != nil { + return f.grantErr + } + f.roles = append(f.roles, ar) + return nil +} +func (f *fakeAuthActorSvc) Revoke(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType, _ string) error { + return f.revokeErr +} +func (f *fakeAuthActorSvc) ListForActor(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType) ([]*authdomain.ActorRole, error) { + return f.roles, nil +} +func (f *fakeAuthActorSvc) EffectivePermissions(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType) ([]repository.EffectivePermission, error) { + return f.effective, nil +} + +type fakePermChecker struct { + check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) +} + +func (f *fakePermChecker) CheckPermission(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) { + if f.check == nil { + return true, nil + } + return f.check(ctx, actorID, actorType, tenantID, perm, scopeType, scopeID) +} + +func newAuthHandlerWithFakes() (AuthHandler, *fakeAuthRoleSvc, *fakeAuthPermSvc, *fakeAuthActorSvc) { + roles := newFakeAuthRoleSvc() + perms := newFakeAuthPermSvc() + actors := newFakeAuthActorSvc() + checker := &fakePermChecker{} + return NewAuthHandler(roles, perms, actors, checker), roles, perms, actors +} + +// withAuthCtx populates the Phase 3 actor context keys on a request. +func withAuthCtx(req *http.Request, actorID, actorType string) *http.Request { + ctx := req.Context() + ctx = context.WithValue(ctx, auth.ActorIDKey{}, actorID) + ctx = context.WithValue(ctx, auth.ActorTypeKey{}, actorType) + return req.WithContext(ctx) +} + +// ============================================================================= +// Tests +// ============================================================================= + +func TestAuthHandler_NoActorReturns401(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles", nil) + rec := httptest.NewRecorder() + h.ListRoles(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("ListRoles without actor should yield 401; got %d", rec.Code) + } +} + +func TestAuthHandler_ListRolesReturnsAllRoles(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.roles["r-admin"] = &authdomain.Role{ID: "r-admin", Name: "admin"} + roleSvc.roles["r-viewer"] = &authdomain.Role{ID: "r-viewer", Name: "viewer"} + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles", nil), "alice", auth.ActorTypeAPIKey) + rec := httptest.NewRecorder() + h.ListRoles(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("got %d; body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Roles []roleResponse `json:"roles"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Roles) != 2 { + t.Errorf("expected 2 roles; got %d", len(resp.Roles)) + } +} + +func TestAuthHandler_CreateRoleReturns201(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + body, _ := json.Marshal(createRoleRequest{Name: "custom", Description: "Test role"}) + req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles", bytes.NewReader(body)), "alice", auth.ActorTypeAPIKey) + rec := httptest.NewRecorder() + h.CreateRole(rec, req) + if rec.Code != http.StatusCreated { + t.Errorf("expected 201; got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAuthHandler_CreateRoleRejectsEmptyName(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + body, _ := json.Marshal(createRoleRequest{Name: " ", Description: "blank"}) + req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles", bytes.NewReader(body)), "alice", auth.ActorTypeAPIKey) + rec := httptest.NewRecorder() + h.CreateRole(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("blank name should be 400; got %d", rec.Code) + } +} + +func TestAuthHandler_DeleteRoleReturns204(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "x"} + req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-x", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-x") + rec := httptest.NewRecorder() + h.DeleteRole(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("delete should be 204; got %d", rec.Code) + } +} + +func TestAuthHandler_DeleteRoleInUseReturns409(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.deleteErr = repository.ErrAuthRoleInUse + req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-x", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-x") + rec := httptest.NewRecorder() + h.DeleteRole(rec, req) + if rec.Code != http.StatusConflict { + t.Errorf("ErrAuthRoleInUse should be 409; got %d", rec.Code) + } +} + +func TestAuthHandler_DeleteRoleNotFoundReturns404(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.deleteErr = repository.ErrAuthNotFound + req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/missing", nil), "alice", auth.ActorTypeAPIKey) + req.SetPathValue("id", "missing") + rec := httptest.NewRecorder() + h.DeleteRole(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("ErrAuthNotFound should be 404; got %d", rec.Code) + } +} + +func TestAuthHandler_ForbiddenMappedTo403(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.listErr = authsvc.ErrForbidden + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles", nil), "bob", auth.ActorTypeAPIKey) + rec := httptest.NewRecorder() + h.ListRoles(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("ErrForbidden should be 403; got %d", rec.Code) + } +} + +func TestAuthHandler_AssignRoleToKey(t *testing.T) { + h, _, _, actorSvc := newAuthHandlerWithFakes() + body, _ := json.Marshal(assignRoleRequest{RoleID: "r-viewer"}) + req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/keys/alice/roles", bytes.NewReader(body)), "admin", auth.ActorTypeAPIKey) + req.SetPathValue("id", "alice") + rec := httptest.NewRecorder() + h.AssignRoleToKey(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204; got %d, body=%s", rec.Code, rec.Body.String()) + } + if len(actorSvc.roles) != 1 { + t.Errorf("expected 1 grant recorded; got %d", len(actorSvc.roles)) + } + if actorSvc.roles[0].RoleID != "r-viewer" || actorSvc.roles[0].ActorID != "alice" { + t.Errorf("grant fields wrong; got %+v", actorSvc.roles[0]) + } +} + +func TestAuthHandler_AssignRoleSelfRoleAssignReturns403(t *testing.T) { + h, _, _, actorSvc := newAuthHandlerWithFakes() + actorSvc.grantErr = errors.New("auth.role.assign required: " + authsvc.ErrSelfRoleAssignment.Error()) + // Force the wrapped sentinel: + actorSvc.grantErr = authsvc.ErrSelfRoleAssignment + body, _ := json.Marshal(assignRoleRequest{RoleID: "r-admin"}) + req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/keys/alice/roles", bytes.NewReader(body)), "bob", auth.ActorTypeAPIKey) + req.SetPathValue("id", "alice") + rec := httptest.NewRecorder() + h.AssignRoleToKey(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("ErrSelfRoleAssignment should be 403; got %d", rec.Code) + } +} + +func TestAuthHandler_RevokeRoleFromKey(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/keys/alice/roles/r-viewer", nil), "admin", auth.ActorTypeAPIKey) + req.SetPathValue("id", "alice") + req.SetPathValue("role_id", "r-viewer") + rec := httptest.NewRecorder() + h.RevokeRoleFromKey(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("revoke should be 204; got %d", rec.Code) + } +} + +func TestAuthHandler_RevokeReservedActorReturns409(t *testing.T) { + h, _, _, actorSvc := newAuthHandlerWithFakes() + actorSvc.revokeErr = repository.ErrAuthReservedActor + req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/keys/actor-demo-anon/roles/r-admin", nil), "admin", auth.ActorTypeAPIKey) + req.SetPathValue("id", "actor-demo-anon") + req.SetPathValue("role_id", "r-admin") + rec := httptest.NewRecorder() + h.RevokeRoleFromKey(rec, req) + if rec.Code != http.StatusConflict { + t.Errorf("ErrAuthReservedActor should be 409; got %d", rec.Code) + } +} + +func TestAuthHandler_AddRolePermissionInvalidJSON(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles/r-admin/permissions", strings.NewReader("not json")), "admin", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-admin") + rec := httptest.NewRecorder() + h.AddRolePermission(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("invalid JSON should be 400; got %d", rec.Code) + } +} + +func TestAuthHandler_AddRolePermissionDefaultScopeGlobal(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + body, _ := json.Marshal(addPermissionRequest{Permission: "cert.read"}) + req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles/r-admin/permissions", bytes.NewReader(body)), "admin", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-admin") + rec := httptest.NewRecorder() + h.AddRolePermission(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204; got %d, body=%s", rec.Code, rec.Body.String()) + } + grants := roleSvc.rolePerms["r-admin"] + if len(grants) != 1 { + t.Fatalf("expected 1 grant; got %d", len(grants)) + } + if grants[0].ScopeType != authdomain.ScopeTypeGlobal { + t.Errorf("default scope should be global; got %q", grants[0].ScopeType) + } +} + +func TestAuthHandler_AddRolePermissionInvalidPermission(t *testing.T) { + h, roleSvc, _, _ := newAuthHandlerWithFakes() + roleSvc.addPermErr = authsvc.ErrInvalidPermission + body, _ := json.Marshal(addPermissionRequest{Permission: "fake"}) + req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles/r-admin/permissions", bytes.NewReader(body)), "admin", auth.ActorTypeAPIKey) + req.SetPathValue("id", "r-admin") + rec := httptest.NewRecorder() + h.AddRolePermission(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("ErrInvalidPermission should be 400; got %d", rec.Code) + } +} + +func TestAuthHandler_ListPermissionsReturnsCanonical(t *testing.T) { + h, _, _, _ := newAuthHandlerWithFakes() + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/permissions", nil), "alice", auth.ActorTypeAPIKey) + rec := httptest.NewRecorder() + h.ListPermissions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("got %d", rec.Code) + } + var resp struct { + Permissions []permissionResponse `json:"permissions"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Permissions) != len(authdomain.CanonicalPermissions) { + t.Errorf("permission count: got %d, want %d (canonical catalogue size)", len(resp.Permissions), len(authdomain.CanonicalPermissions)) + } +} + +func TestAuthHandler_MeReturnsActorIdentity(t *testing.T) { + h, _, _, actorSvc := newAuthHandlerWithFakes() + actorSvc.roles = []*authdomain.ActorRole{ + {RoleID: "r-admin", ActorID: "alice"}, + } + actorSvc.effective = []repository.EffectivePermission{ + {PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), "alice", auth.ActorTypeAPIKey) + rec := httptest.NewRecorder() + h.Me(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("got %d; body=%s", rec.Code, rec.Body.String()) + } + var resp meResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ActorID != "alice" { + t.Errorf("actor id = %q, want alice", resp.ActorID) + } + if !resp.Admin { + t.Errorf("alice has r-admin; admin flag should be true (back-compat)") + } + if len(resp.EffectivePermissions) != 1 || resp.EffectivePermissions[0].Permission != "cert.read" { + t.Errorf("effective_permissions wrong; got %+v", resp.EffectivePermissions) + } +} diff --git a/internal/api/router/openapi_parity_test.go b/internal/api/router/openapi_parity_test.go index e81c70f..8be50c2 100644 --- a/internal/api/router/openapi_parity_test.go +++ b/internal/api/router/openapi_parity_test.go @@ -92,6 +92,24 @@ var SpecParityExceptions = map[string]string{ "POST /acme/key-change": "Phase 4 default-profile shorthand for key rollover.", "POST /acme/revoke-cert": "Phase 4 default-profile shorthand for revoke-cert.", "GET /acme/renewal-info/{cert_id}": "Phase 4 default-profile shorthand for ARI.", + + // Bundle 1 / Phase 4 RBAC API: routes registered in this commit; + // OpenAPI schema entries land in a Phase 4 follow-up commit so the + // schema review is its own atomic change. Each route's request / + // response shape is documented in internal/api/handler/auth.go's + // type definitions; the OpenAPI section lift will mirror those. + // Routes: + "GET /api/v1/auth/me": "Bundle 1 Phase 4 RBAC: current actor's effective permissions; OpenAPI follow-up.", + "GET /api/v1/auth/permissions": "Bundle 1 Phase 4 RBAC: canonical permission catalogue; OpenAPI follow-up.", + "GET /api/v1/auth/roles": "Bundle 1 Phase 4 RBAC: list roles; OpenAPI follow-up.", + "POST /api/v1/auth/roles": "Bundle 1 Phase 4 RBAC: create role; OpenAPI follow-up.", + "GET /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: get role + permissions; OpenAPI follow-up.", + "PUT /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: update role; OpenAPI follow-up.", + "DELETE /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: delete role; OpenAPI follow-up.", + "POST /api/v1/auth/roles/{id}/permissions": "Bundle 1 Phase 4 RBAC: grant permission to role; OpenAPI follow-up.", + "DELETE /api/v1/auth/roles/{id}/permissions/{perm}": "Bundle 1 Phase 4 RBAC: revoke permission from role; OpenAPI follow-up.", + "POST /api/v1/auth/keys/{id}/roles": "Bundle 1 Phase 4 RBAC: assign role to API key; OpenAPI follow-up.", + "DELETE /api/v1/auth/keys/{id}/roles/{role_id}": "Bundle 1 Phase 4 RBAC: revoke role from API key; OpenAPI follow-up.", } func TestRouter_OpenAPIParity(t *testing.T) { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 0a082bb..3aa77a8 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -112,6 +112,12 @@ type HandlerRegistry struct { Digest handler.DigestHandler HealthChecks *handler.HealthCheckHandler BulkRevocation handler.BulkRevocationHandler + + // Auth (Bundle 1 Phase 4) handles RBAC management endpoints under + // /api/v1/auth/{roles,permissions,keys,me}. Wired in cmd/server with + // the service-layer Authorizer + RoleService + ActorRoleService + + // PermissionService dependencies. Phase 5 ships the CLI mirror. + Auth handler.AuthHandler // L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a): // server-side bulk endpoints replace pre-L-1 client-side N×HTTP // loops in CertificatesPage.tsx. See handler/bulk_renewal.go and @@ -218,6 +224,23 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { // Auth check endpoint (uses full middleware chain via r.Register) r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck)) + // RBAC management routes (Bundle 1 Phase 4). Permission gates are + // enforced inside each handler via the service layer; the Phase 3 + // auth.RequirePermission middleware factory will wrap these in a + // Phase 3.5 router-level pass once the legacy admin handlers are + // converted in lockstep. + r.Register("GET /api/v1/auth/me", http.HandlerFunc(reg.Auth.Me)) + r.Register("GET /api/v1/auth/permissions", http.HandlerFunc(reg.Auth.ListPermissions)) + r.Register("GET /api/v1/auth/roles", http.HandlerFunc(reg.Auth.ListRoles)) + r.Register("POST /api/v1/auth/roles", http.HandlerFunc(reg.Auth.CreateRole)) + r.Register("GET /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.GetRole)) + r.Register("PUT /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.UpdateRole)) + r.Register("DELETE /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.DeleteRole)) + r.Register("POST /api/v1/auth/roles/{id}/permissions", http.HandlerFunc(reg.Auth.AddRolePermission)) + r.Register("DELETE /api/v1/auth/roles/{id}/permissions/{perm}", http.HandlerFunc(reg.Auth.RemoveRolePermission)) + r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey)) + r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey)) + // Certificates routes: /api/v1/certificates // Bulk operations MUST register before {id} routes — Go 1.22 ServeMux // gives literal segments precedence over pattern-var segments, but diff --git a/internal/cli/auth.go b/internal/cli/auth.go new file mode 100644 index 0000000..8d8ccfa --- /dev/null +++ b/internal/cli/auth.go @@ -0,0 +1,253 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// ============================================================================= +// CLI auth subcommands. Bundle 1 Phase 5 mirrors the /api/v1/auth/* +// surface introduced in Phase 4. Read operations + key-role assignment + +// the /me identity check; mutating role lifecycle (create / update / +// delete) is a Phase 5.5 follow-up that adds the cobra-style flag +// parsing for description / name fields. +// ============================================================================= + +// authMeResponse mirrors handler.meResponse without importing the +// handler package (would couple CLI build to the server tree). +type authMeResponse struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + TenantID string `json:"tenant_id"` + Admin bool `json:"admin"` + Roles []string `json:"roles"` + EffectivePermissions []struct { + Permission string `json:"permission"` + ScopeType string `json:"scope_type"` + ScopeID *string `json:"scope_id,omitempty"` + } `json:"effective_permissions"` +} + +// AuthMe prints the current actor's identity + permissions. Useful for +// debugging RBAC config: confirms which actor the API key resolves to, +// which roles it holds, and the effective permission set. +func (c *Client) AuthMe() error { + body, err := c.doGET("/api/v1/auth/me") + if err != nil { + return err + } + if c.format == "json" { + fmt.Println(string(body)) + return nil + } + var me authMeResponse + if err := json.Unmarshal(body, &me); err != nil { + return fmt.Errorf("decode /auth/me: %w", err) + } + fmt.Printf("Actor: %s (%s)\n", me.ActorID, me.ActorType) + fmt.Printf("Tenant: %s\n", me.TenantID) + fmt.Printf("Admin: %t\n", me.Admin) + fmt.Printf("Roles: %s\n", strings.Join(me.Roles, ", ")) + fmt.Printf("Effective permissions:\n") + for _, p := range me.EffectivePermissions { + scope := p.ScopeType + if p.ScopeID != nil { + scope = fmt.Sprintf("%s:%s", p.ScopeType, *p.ScopeID) + } + fmt.Printf(" %s @ %s\n", p.Permission, scope) + } + return nil +} + +// AuthListRoles prints all roles in the tenant. +func (c *Client) AuthListRoles() error { + body, err := c.doGET("/api/v1/auth/roles") + if err != nil { + return err + } + if c.format == "json" { + fmt.Println(string(body)) + return nil + } + var resp struct { + Roles []struct { + ID, Name, Description string + TenantID string `json:"tenant_id"` + } `json:"roles"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("decode roles list: %w", err) + } + fmt.Printf("%-15s %-15s %s\n", "ID", "NAME", "DESCRIPTION") + for _, r := range resp.Roles { + fmt.Printf("%-15s %-15s %s\n", r.ID, r.Name, r.Description) + } + return nil +} + +// AuthGetRole prints a single role + its permission grants. +func (c *Client) AuthGetRole(id string) error { + body, err := c.doGET("/api/v1/auth/roles/" + id) + if err != nil { + return err + } + if c.format == "json" { + fmt.Println(string(body)) + return nil + } + var resp struct { + Role struct { + ID, Name, Description string + } + Permissions []struct { + PermissionID string `json:"permission_id"` + ScopeType string `json:"scope_type"` + ScopeID *string `json:"scope_id,omitempty"` + } + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("decode role: %w", err) + } + fmt.Printf("ID: %s\n", resp.Role.ID) + fmt.Printf("Name: %s\n", resp.Role.Name) + fmt.Printf("Description: %s\n", resp.Role.Description) + fmt.Printf("Permissions (%d):\n", len(resp.Permissions)) + for _, p := range resp.Permissions { + scope := p.ScopeType + if p.ScopeID != nil { + scope = fmt.Sprintf("%s:%s", p.ScopeType, *p.ScopeID) + } + fmt.Printf(" %s @ %s\n", p.PermissionID, scope) + } + return nil +} + +// AuthListPermissions prints the canonical permission catalogue. +func (c *Client) AuthListPermissions() error { + body, err := c.doGET("/api/v1/auth/permissions") + if err != nil { + return err + } + if c.format == "json" { + fmt.Println(string(body)) + return nil + } + var resp struct { + Permissions []struct { + ID, Name, Namespace string + } `json:"permissions"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("decode permissions: %w", err) + } + fmt.Printf("%-25s %s\n", "PERMISSION", "NAMESPACE") + for _, p := range resp.Permissions { + fmt.Printf("%-25s %s\n", p.Name, p.Namespace) + } + return nil +} + +// AuthAssignRoleToKey grants a role to an API-key-named actor. The +// caller's key must hold auth.role.assign globally; service-layer +// returns 403 otherwise. +func (c *Client) AuthAssignRoleToKey(keyID, roleID string) error { + body, err := json.Marshal(map[string]string{"role_id": roleID}) + if err != nil { + return err + } + if _, err := c.doPOST("/api/v1/auth/keys/"+keyID+"/roles", body); err != nil { + return err + } + fmt.Printf("granted %s to %s\n", roleID, keyID) + return nil +} + +// AuthRevokeRoleFromKey revokes a role from an API-key-named actor. +// Service-layer rejects revocations against the reserved demo-anon +// actor with 409; CLI surfaces that as a non-zero exit. +func (c *Client) AuthRevokeRoleFromKey(keyID, roleID string) error { + if err := c.doDELETE("/api/v1/auth/keys/" + keyID + "/roles/" + roleID); err != nil { + return err + } + fmt.Printf("revoked %s from %s\n", roleID, keyID) + return nil +} + +// ============================================================================= +// HTTP helpers — minimal wrappers around the underlying http.Client used +// elsewhere in the package. Mirror the pattern from est.go (same +// authentication + TLS + error-handling shape). +// ============================================================================= + +func (c *Client) doGET(path string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, err + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + return c.doRaw(req) +} + +func (c *Client) doPOST(path string, body []byte) ([]byte, error) { + req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + return c.doRaw(req) +} + +func (c *Client) doDELETE(path string) error { + req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, nil) + if err != nil { + return err + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + _, err = c.doRaw(req) + return err +} + +func (c *Client) doRaw(req *http.Request) ([]byte, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := readAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +// readAll wraps io.ReadAll without pulling another import; defined as a +// thin function so we can swap to a bounded reader later if needed. +func readAll(r interface{ Read(p []byte) (int, error) }) ([]byte, error) { + var buf []byte + tmp := make([]byte, 4096) + for { + n, err := r.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + if err.Error() == "EOF" { + return buf, nil + } + return buf, err + } + } +}