mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 09:48:55 +00:00
auth-bundle-1 Phase 3 (primitive): RequirePermission middleware + demo-mode + protocol allowlist
Bundle 1 / Phase 3 (primitive ship): the load-bearing RBAC middleware factory plus its dependencies. Handler conversion sweep (5 admin files: bulk_revocation.go, admin_crl_cache.go, admin_scep_intune.go, admin_est.go, intermediate_ca.go) + m008_admin_gate_test.go registry update is Phase 3.5 follow-on; this commit ships the primitive so 3.5 is mechanical. New context keys (internal/auth/context.go): ActorIDKey, ActorTypeKey, TenantIDKey alongside the legacy UserKey + AdminKey. New helpers GetActorID / GetActorType / GetTenantID with safe fallbacks (UserKey for actor id, ActorTypeAPIKey for missing type, DefaultTenantID for missing tenant). Constants DemoAnonActorID + ActorTypeAPIKey + ActorTypeAnonymous mirror internal/domain/auth without an import cycle. RequirePermission factory (internal/auth/require_permission.go): wraps a handler and gates it behind a named permission. 401 when no actor, 403 when actor lacks permission, 500 on repository error. Skips the gate entirely for protocol endpoints (ACME / SCEP / EST / OCSP / CRL) per the audit's Category F do-not-gate allowlist. PermissionChecker is an interface so internal/auth doesn't depend on internal/service/auth (cmd/server wires the concrete Authorizer at startup). HasPermission is the imperative variant for handlers that branch behaviour rather than 403'ing. ScopeFunc closure extracts the scope type + id from the request for per-resource gating. Protocol-endpoint allowlist (internal/auth/protocol_endpoints.go): IsProtocolEndpoint matches /acme, /scep, /.well-known/est, /.well-known/pki/ocsp, /.well-known/pki/crl prefixes. Adding a new protocol endpoint MUST update this list and add a parallel test. Demo-mode synthetic admin (internal/auth/middleware.go::NewDemoModeAuth): when CERTCTL_AUTH_TYPE=none is configured, this middleware injects ActorID=actor-demo-anon, ActorType=Anonymous, TenantID=t-default, plus the legacy UserKey + AdminKey for back-compat with existing handlers. The synthetic actor's admin-role grant is seeded by migration 000029 so RequirePermission resolves through the JOIN like any other actor. cmd/server startup wires this middleware only when none-mode is configured. API-key middleware extension: NewAuthWithNamedKeys now populates the new keys (ActorIDKey, ActorTypeKey=APIKey, TenantIDKey=t-default) alongside UserKey + AdminKey on every successful Bearer match. Existing handlers continue to read UserKey / IsAdmin until the Phase 3.5 sweep converts them to RequirePermission. Test coverage: TestRequirePermission_NoActorReturns401, TestRequirePermission_GrantedActorReaches200, TestRequirePermission_DeniedActorReturns403, TestRequirePermission_CheckerErrorReturns500, TestRequirePermission_ProtocolEndpointBypassesGate (covers all 5 prefixes), TestRequirePermission_ScopeFnExtractsResourceID, TestIsProtocolEndpoint_PrefixesOnly, TestNewDemoModeAuth_InjectsSyntheticActor, TestNewAuthWithNamedKeys_PopulatesPhase3ContextKeys. fakeChecker pins the contract without a database. Phase 3.5 follow-on (NOT in this commit): convert each of the 5 admin handlers from auth.IsAdmin checks to auth.RequirePermission middleware in router.go; update internal/api/handler/m008_admin_gate_test.go to track auth.RequirePermission call sites instead of (or alongside) auth.IsAdmin; pick the right permission per handler (cert.revoke for bulk_revocation, etc.). Each handler conversion needs the 3-test triplet (_NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 / _AdminPermitted_ForwardsActor) per M-008. Branch: dev/auth-bundle-1. Phase 2 was prior commit (service layer). Phase 3.5 (handler conversion) + Phase 4 (HTTP API) on the next session.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// PermissionChecker is the dependency the RequirePermission middleware
|
||||
// expects. internal/service/auth.Authorizer satisfies this interface;
|
||||
// tests can supply an in-memory fake.
|
||||
//
|
||||
// scopeID is nil for global checks; non-nil for per-resource checks
|
||||
// (e.g. per-profile or per-issuer scoping). scopeType matches
|
||||
// internal/domain/auth.ScopeType ("global", "profile", "issuer").
|
||||
type PermissionChecker interface {
|
||||
CheckPermission(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType string,
|
||||
tenantID string,
|
||||
permission string,
|
||||
scopeType string,
|
||||
scopeID *string,
|
||||
) (bool, error)
|
||||
}
|
||||
|
||||
// ScopeFunc extracts the scope (type, id) from the request. A nil
|
||||
// ScopeFunc means "global scope" (the most common case for admin-class
|
||||
// gates like bulk revocation, intermediate-CA management, etc.).
|
||||
type ScopeFunc func(r *http.Request) (scopeType string, scopeID *string)
|
||||
|
||||
// RequirePermission returns a middleware that gates the wrapped handler
|
||||
// behind the named permission. Returns 401 when no actor is in
|
||||
// context, 403 when the actor exists but lacks the permission, 500 on
|
||||
// repository errors. Skips the gate entirely for protocol-level
|
||||
// endpoints in ProtocolEndpointPrefixes (ACME / SCEP / EST / OCSP / CRL).
|
||||
//
|
||||
// The permission name MUST exist in
|
||||
// internal/domain/auth.CanonicalPermissions (enforced indirectly via
|
||||
// the seed migration; an unknown permission name will simply return
|
||||
// 403 because no role grant references it).
|
||||
func RequirePermission(checker PermissionChecker, permission string, scope ScopeFunc) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Protocol endpoints keep their existing protocol-level
|
||||
// auth; the RBAC gate doesn't apply.
|
||||
if IsProtocolEndpoint(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
actorID := GetActorID(ctx)
|
||||
if actorID == "" {
|
||||
writeJSONError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
actorType := GetActorType(ctx)
|
||||
if actorType == "" {
|
||||
// Legacy callers that only set UserKey: assume APIKey.
|
||||
// Bundle 2's OIDC middleware sets the type explicitly
|
||||
// to "User"; the demo-mode middleware sets it to
|
||||
// "Anonymous"; the API-key middleware (Phase 3
|
||||
// extension) sets it to "APIKey".
|
||||
actorType = ActorTypeAPIKey
|
||||
}
|
||||
|
||||
scopeType := "global"
|
||||
var scopeID *string
|
||||
if scope != nil {
|
||||
scopeType, scopeID = scope(r)
|
||||
}
|
||||
|
||||
tenantID := GetTenantID(ctx)
|
||||
ok, err := checker.CheckPermission(ctx, actorID, actorType, tenantID, permission, scopeType, scopeID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "RBAC check failed",
|
||||
"permission", permission,
|
||||
"actor_id", actorID,
|
||||
"error", err,
|
||||
)
|
||||
writeJSONError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HasPermission is a convenience for handlers that need to check a
|
||||
// permission imperatively (e.g. branch behaviour without 403'ing the
|
||||
// whole request). Returns (true, nil) when granted, (false, nil) when
|
||||
// denied, (false, err) on repository failure. Skips the protocol-
|
||||
// endpoint allowlist.
|
||||
func HasPermission(ctx context.Context, checker PermissionChecker, permission string, scopeType string, scopeID *string) (bool, error) {
|
||||
actorID := GetActorID(ctx)
|
||||
if actorID == "" {
|
||||
return false, ErrNoActor
|
||||
}
|
||||
actorType := GetActorType(ctx)
|
||||
if actorType == "" {
|
||||
actorType = ActorTypeAPIKey
|
||||
}
|
||||
tenantID := GetTenantID(ctx)
|
||||
return checker.CheckPermission(ctx, actorID, actorType, tenantID, permission, scopeType, scopeID)
|
||||
}
|
||||
|
||||
// ErrNoActor is returned by HasPermission when the request context has
|
||||
// no actor identity. Handler code typically translates this to HTTP
|
||||
// 401.
|
||||
var ErrNoActor = errors.New("auth: no actor in context")
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
// Match the existing middleware error shape so handler tests that
|
||||
// assert on the body text continue to work.
|
||||
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||
}
|
||||
Reference in New Issue
Block a user