mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:11:30 +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:
@@ -48,3 +48,85 @@ func IsAdmin(ctx context.Context) bool {
|
||||
admin, ok := ctx.Value(AdminKey{}).(bool)
|
||||
return ok && admin
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 3: RBAC-aware context keys.
|
||||
//
|
||||
// ActorIDKey, ActorTypeKey, and TenantIDKey are populated by the auth
|
||||
// middleware (NewAuthWithNamedKeys, NewDemoModeAuth, and Bundle 2's
|
||||
// session middleware) so that downstream RBAC checks have a stable
|
||||
// identity + tenancy view of the caller.
|
||||
//
|
||||
// UserKey + AdminKey continue to be populated for back-compat with
|
||||
// existing audit / rate-limiter / handler code; the new keys are the
|
||||
// canonical Phase 3+ identity.
|
||||
// =============================================================================
|
||||
|
||||
// ActorIDKey is the canonical actor identifier (e.g. an API-key name,
|
||||
// an OIDC user id, or the synthetic `actor-demo-anon`). Phase 3
|
||||
// middleware populates this; auth.RequirePermission and
|
||||
// auth.CallerFromContext read it.
|
||||
type ActorIDKey struct{}
|
||||
|
||||
// ActorTypeKey is the typed-string actor type (User, System, Agent,
|
||||
// APIKey, Anonymous) corresponding to internal/domain.ActorType. Stored
|
||||
// as a string so the internal/auth package doesn't need to import the
|
||||
// domain package and create a cycle.
|
||||
type ActorTypeKey struct{}
|
||||
|
||||
// TenantIDKey is the tenant the request executes in. Bundle 1 ships
|
||||
// single-tenant; every authenticated request gets the seeded
|
||||
// `t-default` tenant unless the future managed-service offering
|
||||
// configures a different one.
|
||||
type TenantIDKey struct{}
|
||||
|
||||
// GetActorID returns the canonical actor id from context, or "" when
|
||||
// no actor is present (anonymous request, missing middleware in test
|
||||
// harnesses, etc.). Falls back to the legacy UserKey value for
|
||||
// back-compat with handlers that have not yet adopted the new keys.
|
||||
func GetActorID(ctx context.Context) string {
|
||||
if id, ok := ctx.Value(ActorIDKey{}).(string); ok && id != "" {
|
||||
return id
|
||||
}
|
||||
return GetUser(ctx)
|
||||
}
|
||||
|
||||
// GetActorType returns the actor type string from context, or "" when
|
||||
// no actor type was set. Phase 3 middleware sets this to "APIKey" for
|
||||
// validated bearer-token requests and "Anonymous" for the demo-mode
|
||||
// synthetic actor.
|
||||
func GetActorType(ctx context.Context) string {
|
||||
if t, ok := ctx.Value(ActorTypeKey{}).(string); ok {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetTenantID returns the tenant id from context, or the seeded
|
||||
// default tenant when no value was set. Returning the default rather
|
||||
// than "" keeps RBAC lookups working in deployments that haven't
|
||||
// configured a tenant explicitly (the Bundle 1 baseline).
|
||||
func GetTenantID(ctx context.Context) string {
|
||||
if t, ok := ctx.Value(TenantIDKey{}).(string); ok && t != "" {
|
||||
return t
|
||||
}
|
||||
return DefaultTenantID
|
||||
}
|
||||
|
||||
// DefaultTenantID is the seeded single tenant. Mirrors
|
||||
// internal/domain/auth.DefaultTenantID; duplicated here to avoid a
|
||||
// cross-package import in the hot-path middleware.
|
||||
const DefaultTenantID = "t-default"
|
||||
|
||||
// DemoAnonActorID is the synthetic actor id used by the demo-mode
|
||||
// auth middleware when CERTCTL_AUTH_TYPE=none. Mirrors
|
||||
// internal/domain/auth.DemoAnonActorID.
|
||||
const DemoAnonActorID = "actor-demo-anon"
|
||||
|
||||
// ActorTypeAPIKey + ActorTypeAnonymous mirror the corresponding
|
||||
// domain.ActorType values. Stored as untyped strings here so callers
|
||||
// don't have to import the domain package.
|
||||
const (
|
||||
ActorTypeAPIKey = "APIKey"
|
||||
ActorTypeAnonymous = "Anonymous"
|
||||
)
|
||||
|
||||
@@ -100,9 +100,44 @@ func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handl
|
||||
return
|
||||
}
|
||||
|
||||
// Store the authenticated identity and admin flag in context
|
||||
// Store the authenticated identity and admin flag in context.
|
||||
// Bundle 1 Phase 0: legacy UserKey + AdminKey for back-compat.
|
||||
// Bundle 1 Phase 3: new ActorIDKey + ActorTypeKey + TenantIDKey
|
||||
// for RBAC-aware downstream code (RequirePermission, etc.).
|
||||
ctx := context.WithValue(r.Context(), UserKey{}, matched.name)
|
||||
ctx = context.WithValue(ctx, AdminKey{}, matched.admin)
|
||||
ctx = context.WithValue(ctx, ActorIDKey{}, matched.name)
|
||||
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey)
|
||||
ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewDemoModeAuth returns a middleware that injects the synthetic
|
||||
// `actor-demo-anon` identity into every request context. Used when
|
||||
// CERTCTL_AUTH_TYPE=none is configured (the demo path) so that
|
||||
// RBAC-gated handlers see an admin-equivalent caller without operator
|
||||
// configuration.
|
||||
//
|
||||
// The synthetic actor is seeded by migration 000029_rbac.up.sql with
|
||||
// the admin role at global scope, so RequirePermission resolves
|
||||
// every gated request as an admin. The reserved-actor guard in the
|
||||
// service layer prevents the API from accidentally mutating this
|
||||
// actor's role assignments.
|
||||
//
|
||||
// Production deployments MUST NOT use this middleware. The cmd/server
|
||||
// startup wires it only when CERTCTL_AUTH_TYPE=none is explicitly
|
||||
// configured.
|
||||
func NewDemoModeAuth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserKey{}, DemoAnonActorID)
|
||||
ctx = context.WithValue(ctx, AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, ActorIDKey{}, DemoAnonActorID)
|
||||
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAnonymous)
|
||||
ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
|
||||
// ProtocolEndpointPrefixes lists the URL path prefixes that authenticate
|
||||
// via the protocol itself rather than via certctl's Bearer / cookie
|
||||
// stack. Bundle 1 Phase 3 uses this allowlist as the explicit "do NOT
|
||||
// wrap with RequirePermission" set: the RBAC middleware applies only to
|
||||
// admin handlers replacing legacy IsAdmin checks plus any new
|
||||
// permission-gated routes; the endpoints below keep their existing
|
||||
// protocol-level auth.
|
||||
//
|
||||
// Adding a new protocol endpoint that doesn't take a Bearer token MUST
|
||||
// also add the prefix here and a parallel test in Phase 12 asserting
|
||||
// the route is unwrapped.
|
||||
//
|
||||
// Per the Phase 3 audit:
|
||||
//
|
||||
// ACME server : /acme/profile/<id>/* + /acme/* (JWS-signed, RFC 8555).
|
||||
// SCEP server : /scep (challenge password +
|
||||
// signed CSR, RFC 8894).
|
||||
// EST server : /.well-known/est/* (mTLS client cert,
|
||||
// RFC 7030).
|
||||
// OCSP responder : /.well-known/pki/ocsp (RFC 6960, public).
|
||||
// CRL distrib. : /.well-known/pki/crl/* (RFC 5280, public).
|
||||
//
|
||||
// Plus the existing public-route bypass list at internal/api/router
|
||||
// (router.go:69-72): /health, /ready, /api/v1/auth/info. Those bypass
|
||||
// EVERY middleware stack, not just RBAC, so they're not in this
|
||||
// allowlist; they're handled in router.go directly.
|
||||
var ProtocolEndpointPrefixes = []string{
|
||||
"/acme",
|
||||
"/scep",
|
||||
"/.well-known/est",
|
||||
"/.well-known/pki/ocsp",
|
||||
"/.well-known/pki/crl",
|
||||
}
|
||||
|
||||
// IsProtocolEndpoint reports whether the request path is in the
|
||||
// "do not gate" allowlist. Phase 3 RequirePermission check bails out
|
||||
// early for these paths so the protocol surface is preserved.
|
||||
func IsProtocolEndpoint(path string) bool {
|
||||
for _, p := range ProtocolEndpointPrefixes {
|
||||
if path == p || strings.HasPrefix(path, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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 + `"}`))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeChecker implements PermissionChecker for unit tests. The check
|
||||
// function controls the result; tests pin specific behaviour via
|
||||
// closures.
|
||||
type fakeChecker struct {
|
||||
check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error)
|
||||
}
|
||||
|
||||
func (f *fakeChecker) CheckPermission(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) {
|
||||
return f.check(ctx, actorID, actorType, tenantID, perm, scopeType, scopeID)
|
||||
}
|
||||
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequirePermission_NoActorReturns401(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
t.Fatalf("checker should not be called when no actor in context")
|
||||
return false, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no actor should yield 401; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_GrantedActorReaches200(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, actorID, actorType, _, perm, _ string, _ *string) (bool, error) {
|
||||
if actorID != "alice" {
|
||||
t.Errorf("actor id = %q, want alice", actorID)
|
||||
}
|
||||
if actorType != ActorTypeAPIKey {
|
||||
t.Errorf("actor type = %q, want %q", actorType, ActorTypeAPIKey)
|
||||
}
|
||||
if perm != "cert.read" {
|
||||
t.Errorf("perm = %q, want cert.read", perm)
|
||||
}
|
||||
return true, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req = req.WithContext(WithActor(req.Context(), "alice"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorTypeKey{}, ActorTypeAPIKey))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("granted actor should reach handler 200; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_DeniedActorReturns403(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
return false, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.delete", nil)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "bob"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorTypeKey{}, ActorTypeAPIKey))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("denied actor should yield 403; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_CheckerErrorReturns500(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
return false, errors.New("database fell over")
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice"))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("checker error should yield 500; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_ProtocolEndpointBypassesGate(t *testing.T) {
|
||||
gateChecks := 0
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
gateChecks++
|
||||
return false, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
for _, p := range []string{
|
||||
"/acme/profile/corp/new-order",
|
||||
"/scep",
|
||||
"/.well-known/est/cacerts",
|
||||
"/.well-known/pki/ocsp",
|
||||
"/.well-known/pki/crl/ca.crl",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||
// Deliberately no actor: protocol endpoints must reach the
|
||||
// handler regardless of context state.
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("protocol endpoint %s should bypass gate; got %d", p, rec.Code)
|
||||
}
|
||||
}
|
||||
if gateChecks != 0 {
|
||||
t.Errorf("checker should be called zero times for protocol endpoints; got %d", gateChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_ScopeFnExtractsResourceID(t *testing.T) {
|
||||
captured := struct {
|
||||
scopeType string
|
||||
scopeID *string
|
||||
}{}
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, st string, sid *string) (bool, error) {
|
||||
captured.scopeType = st
|
||||
captured.scopeID = sid
|
||||
return true, nil
|
||||
}}
|
||||
scope := func(r *http.Request) (string, *string) {
|
||||
id := r.URL.Query().Get("profile")
|
||||
return "profile", &id
|
||||
}
|
||||
mw := RequirePermission(checker, "profile.edit", scope)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/profiles/p-corp?profile=p-corp", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice"))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("scoped grant should pass; got %d", rec.Code)
|
||||
}
|
||||
if captured.scopeType != "profile" {
|
||||
t.Errorf("scope type = %q, want profile", captured.scopeType)
|
||||
}
|
||||
if captured.scopeID == nil || *captured.scopeID != "p-corp" {
|
||||
t.Errorf("scope id = %v, want p-corp", captured.scopeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProtocolEndpoint_PrefixesOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/acme", true},
|
||||
{"/acme/profile/corp/new-order", true},
|
||||
{"/scep", true},
|
||||
// Query strings live in r.URL.RawQuery; r.URL.Path stays
|
||||
// just `/scep`, so callers always pass the path-only form.
|
||||
{"/.well-known/est/cacerts", true},
|
||||
{"/.well-known/pki/ocsp", true},
|
||||
{"/.well-known/pki/crl/ca.crl", true},
|
||||
{"/api/v1/certificates", false},
|
||||
{"/api/v1/auth/me", false},
|
||||
{"/health", false}, // bypassed at the router level, NOT by RBAC.
|
||||
{"/acmedotcom", false},
|
||||
{"/scepfake", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsProtocolEndpoint(tc.path); got != tc.want {
|
||||
t.Errorf("IsProtocolEndpoint(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDemoModeAuth_InjectsSyntheticActor(t *testing.T) {
|
||||
mw := NewDemoModeAuth()
|
||||
var captured struct {
|
||||
actorID, actorType, user string
|
||||
isAdmin bool
|
||||
}
|
||||
handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
captured.actorID = GetActorID(r.Context())
|
||||
captured.actorType = GetActorType(r.Context())
|
||||
captured.user = GetUser(r.Context())
|
||||
captured.isAdmin = IsAdmin(r.Context())
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if captured.actorID != DemoAnonActorID {
|
||||
t.Errorf("actor id = %q, want %q", captured.actorID, DemoAnonActorID)
|
||||
}
|
||||
if captured.actorType != ActorTypeAnonymous {
|
||||
t.Errorf("actor type = %q, want %q", captured.actorType, ActorTypeAnonymous)
|
||||
}
|
||||
if captured.user != DemoAnonActorID {
|
||||
t.Errorf("legacy UserKey = %q, want %q (back-compat)", captured.user, DemoAnonActorID)
|
||||
}
|
||||
if !captured.isAdmin {
|
||||
t.Errorf("legacy AdminKey should be true in demo mode (back-compat for IsAdmin handlers)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthWithNamedKeys_PopulatesPhase3ContextKeys(t *testing.T) {
|
||||
mw := NewAuthWithNamedKeys([]NamedAPIKey{
|
||||
{Name: "alice", Key: "ALICE_KEY", Admin: true},
|
||||
})
|
||||
var captured struct {
|
||||
actorID, actorType, tenantID string
|
||||
}
|
||||
handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
captured.actorID = GetActorID(r.Context())
|
||||
captured.actorType = GetActorType(r.Context())
|
||||
captured.tenantID = GetTenantID(r.Context())
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer ALICE_KEY")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if captured.actorID != "alice" {
|
||||
t.Errorf("Phase 3 actor id = %q, want alice", captured.actorID)
|
||||
}
|
||||
if captured.actorType != ActorTypeAPIKey {
|
||||
t.Errorf("Phase 3 actor type = %q, want %q", captured.actorType, ActorTypeAPIKey)
|
||||
}
|
||||
if captured.tenantID != DefaultTenantID {
|
||||
t.Errorf("Phase 3 tenant id = %q, want %q", captured.tenantID, DefaultTenantID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user