-
Notifications
You must be signed in to change notification settings - Fork 3
BCH-1313: AuthZ Phase 1 — internal/authz (PDP, manifest, builtin driver) + single PEP #427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| return pep.Authorize(authz.ResourceAdmin, authz.ActionManage) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"} | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.