Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func setDefaultEnvironmentVariables() {
viper.SetDefault("ai_queue_workers", "4")
viper.SetDefault("ai_max_calls_per_run", "0")
viper.SetDefault("ai_max_suggestions_per_run", "500")
viper.SetDefault("authz_driver", "builtin")
viper.SetDefault("authz_fail_mode", "closed")
}

func bindEnvironmentVariables() {
Expand Down Expand Up @@ -82,6 +84,8 @@ func bindEnvironmentVariables() {
viper.MustBindEnv("ai_queue_workers")
viper.MustBindEnv("ai_max_calls_per_run")
viper.MustBindEnv("ai_max_suggestions_per_run")
viper.MustBindEnv("authz_driver")
viper.MustBindEnv("authz_fail_mode")
}

func init() {
Expand Down
17 changes: 17 additions & 0 deletions internal/api/handler/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
templatehandlers "github.com/compliance-framework/api/internal/api/handler/templates"
"github.com/compliance-framework/api/internal/api/handler/workflows"
"github.com/compliance-framework/api/internal/api/middleware"
"github.com/compliance-framework/api/internal/authz"
"github.com/compliance-framework/api/internal/config"
"github.com/compliance-framework/api/internal/service/digest"
"github.com/compliance-framework/api/internal/service/notification"
Expand Down Expand Up @@ -41,6 +42,21 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
services.EvidenceService = evidencesvc.NewEvidenceService(db, logger, config, services.RiskEnqueuer)
}

// Central authorization: construct the configured PDP once and wrap it in the single
// PEP. Routes enforce access via pep.Authorize(resource, action) instead of ad-hoc
// checks. The builtin driver reproduces the prior access rules with no behavior change.
authzDriver := ""
failMode := authz.FailClosed
if config.Authz != nil {
authzDriver = config.Authz.Driver
failMode = authz.ParseFailMode(config.Authz.FailMode)
}
pdp, err := authz.Open(authz.Options{Driver: authzDriver}, authz.Deps{DB: db, Config: config, Logger: logger})
if err != nil {
logger.Fatalw("Failed to initialize authorization PDP", "driver", authzDriver, "error", err)
}
pep := middleware.NewPEP(pdp, failMode, logger)

healthHandler := NewHealthHandler(logger, db)
healthHandler.Register(server.API().Group("/health"))

Expand All @@ -60,6 +76,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
evidenceHandler.RegisterCreate(
evidenceGroup,
middleware.OptionalUserOrAgentJWTMiddleware(db, config.JWTPublicKey, !config.StrictDisablePublicAgentEndpoints),
pep.Authorize(authz.ResourceEvidence, authz.ActionCreate),
)
evidenceHandler.RegisterReadRoutes(evidenceGroup)
evidenceSignatureGroup := server.API().Group("/evidence")
Expand Down
94 changes: 18 additions & 76 deletions internal/api/middleware/admin.go
Original file line number Diff line number Diff line change
@@ -1,88 +1,30 @@
package middleware

import (
"errors"
"net/http"
"strings"

"github.com/compliance-framework/api/internal/authn"
"github.com/compliance-framework/api/internal/authz"
"github.com/compliance-framework/api/internal/config"
"github.com/compliance-framework/api/internal/service/relational"
"github.com/compliance-framework/api/internal/service/sso"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"gorm.io/gorm"
)

// RequireAdminGroups enforces that SSO-authenticated users belong to the provider's configured
// admin groups. Password-based logins bypass this middleware (treated as super admins).
// RequireAdminGroups enforces that SSO-authenticated users belong to the provider's
// configured admin groups. Password-based logins bypass this check (treated as super
// admins).
//
// It is now a thin PEP over the builtin authz driver: the enforcement rule lives in
// authz.Builtin so it has a single home and can be served by other drivers in later
// phases, while the access decision is unchanged. Callers keep the same signature.
func RequireAdminGroups(db *gorm.DB, cfg *config.Config, logger *zap.SugaredLogger) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
claims, ok := c.Get("user").(*authn.UserClaims)
if !ok || claims == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "missing authentication claims")
}

var user relational.User
if err := db.Where("email = ?", claims.Subject).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return echo.NewHTTPError(http.StatusForbidden, "user not found")
}
logger.Errorw("Failed to load user for admin enforcement", "email", claims.Subject, "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load user")
}

if strings.ToLower(user.AuthMethod) != "sso" {
// Password (or other non-SSO) users bypass group enforcement.
return next(c)
}
if cfg == nil || cfg.SSO == nil || !cfg.SSO.Enabled {
// Without SSO config we cannot enforce provider-based admin groups; allow request.
return next(c)
}

var link relational.SSOUserLink
if err := db.
Where("user_id = ? AND deleted_at IS NULL", user.ID.String()).
Order("last_sync DESC").
First(&link).Error; err != nil {
logger.Warnw("Missing SSO link for admin enforcement", "userID", user.ID.String(), "error", err)
return echo.NewHTTPError(http.StatusForbidden, "missing SSO link for user")
}

providerConfig := cfg.SSO.GetProvider(link.Provider)
if providerConfig == nil {
logger.Warnw("Provider config not found for admin enforcement", "provider", link.Provider)
// SSO IS enabled and this provider is unknown - we should fail.
return echo.NewHTTPError(http.StatusForbidden, "provider configuration not found")
}

if len(providerConfig.RequiredAdminGroups) == 0 {
return next(c)
}

groupSet := make(map[string]struct{})
for _, g := range sso.DeserializeStringArray(link.Groups) {
normalized := strings.TrimSpace(strings.ToLower(g))
if normalized != "" {
groupSet[normalized] = struct{}{}
}
}

for _, required := range providerConfig.RequiredAdminGroups {
normalized := strings.TrimSpace(strings.ToLower(required))
if _, ok := groupSet[normalized]; !ok {
logger.Warnw("User missing required admin group",
"userID", user.ID.String(),
"requiredGroup", required,
"provider", link.Provider,
)
return echo.NewHTTPError(http.StatusForbidden, "missing required admin groups")
}
}

return next(c)
}
failMode := authz.FailClosed
if cfg != nil && cfg.Authz != nil {
failMode = authz.ParseFailMode(cfg.Authz.FailMode)
}
// Phase 1 hard-wires admin enforcement to the builtin driver, whereas the evidence PEP
// in api.go uses the config-selected driver via authz.Open. These are identical while
// builtin is the only/default driver; they diverge once a second driver is configured
// (admin would stay on builtin). A middleware constructor can't return an error to honor
// an arbitrary driver — route admin through authz.Open when a real second driver lands.
pep := NewPEP(authz.NewBuiltin(db, cfg, logger), failMode, logger)
Comment thread
ccf-lisa[bot] marked this conversation as resolved.
return pep.Authorize(authz.ResourceAdmin, authz.ActionManage)
}
100 changes: 100 additions & 0 deletions internal/api/middleware/authorize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package middleware

import (
"errors"
"net/http"

"github.com/compliance-framework/api/internal/authn"
"github.com/compliance-framework/api/internal/authz"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)

// PEP is the single Policy Enforcement Point. It builds an authz Subject from the
// authenticated principal in the request context and a Resource from the route, asks the
// configured PDP for a decision, and enforces the result: allow → next; deny → 403 (the
// reason is logged, never echoed to the client); PDP unavailable → the configured fail
// mode; any other error → 500. The PEP supplies facts only and holds no policy logic.
type PEP struct {
pdp authz.PDP
failMode authz.FailMode
logger *zap.SugaredLogger
}

// NewPEP constructs a PEP. A nil logger is replaced with a no-op logger and an empty fail
// mode defaults to fail-closed.
func NewPEP(pdp authz.PDP, failMode authz.FailMode, logger *zap.SugaredLogger) *PEP {
if logger == nil {
logger = zap.NewNop().Sugar()
}
if failMode == "" {
failMode = authz.FailClosed
}
return &PEP{pdp: pdp, failMode: failMode, logger: logger}
}

// Authorize returns middleware that enforces (resource, action) for the matched route.
func (p *PEP) Authorize(resource, action string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
subject := subjectFromContext(c)
res := authz.Resource{Type: resource, ID: c.Param("id")}
reqCtx := map[string]any{
"method": c.Request().Method,
"path": c.Path(),
}

decision, err := p.pdp.Evaluate(c.Request().Context(), subject, action, res, reqCtx)
if err != nil {
if errors.Is(err, authz.ErrUnavailable) {
p.logger.Warnw("authz PDP unavailable",
"resource", resource, "action", action, "failMode", p.failMode, "error", err)
if p.failMode == authz.FailOpen {
return next(c)
}
return echo.NewHTTPError(http.StatusForbidden, "forbidden")
}
p.logger.Errorw("authz evaluation failed",
"resource", resource, "action", action, "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, "authorization error")
}

if !decision.Allow {
p.logger.Infow("authz denied",
"resource", resource, "action", action,
"subjectType", subject.Type, "subjectID", subject.ID,
"reason", decision.Reason)
return echo.NewHTTPError(http.StatusForbidden, "forbidden")
}
return next(c)
}
}
}

// subjectFromContext derives the authz Subject from the principal the authn middleware
// placed in the context: an authenticated user, an authenticated agent, or an anonymous
// subject on public-allowed routes. Attributes are intentionally minimal in Phase 1; the
// authoritative attribute surface is designed in BCH-1319.
func subjectFromContext(c echo.Context) authz.Subject {
if claims, ok := c.Get("user").(*authn.UserClaims); ok && claims != nil {
return authz.Subject{
Type: "user",
ID: claims.Subject,
Props: map[string]any{
"given_name": claims.GivenName,
"family_name": claims.FamilyName,
},
}
}
if claims, ok := c.Get("agent_claims").(*authn.AgentClaims); ok && claims != nil {
return authz.Subject{
Type: "agent",
ID: claims.Subject,
Props: map[string]any{
"agent_id": claims.AgentID,
"auth_method": claims.AuthMethod,
},
}
}
return authz.Subject{Type: "anonymous"}
}
Loading
Loading