Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
105309a
feat(authz): add authorization schemas and constants
lakhansamani Apr 13, 2026
42a3965
feat(authz): extend storage provider interface with 28 authorization …
lakhansamani Apr 13, 2026
2954519
feat(authz): implement SQL storage provider for authorization
lakhansamani Apr 13, 2026
6227741
feat(authz): implement MongoDB storage provider for authorization
lakhansamani Apr 13, 2026
0104b1c
feat(authz): implement ArangoDB, Cassandra, Couchbase, DynamoDB provi…
lakhansamani Apr 13, 2026
5a92eaf
feat(authz): implement authorization evaluation engine
lakhansamani Apr 13, 2026
fec92e9
feat(authz): add cache methods to memory store providers
lakhansamani Apr 13, 2026
f9f91d2
feat(authz): add CLI flags and wire authorization provider
lakhansamani Apr 14, 2026
3dc4f98
feat(authz): add authorization GraphQL schema and resolvers
lakhansamani Apr 14, 2026
e2ea245
feat(authz): implement authorization GraphQL handlers
lakhansamani Apr 14, 2026
91d544d
feat(authz): add REST check-permission endpoint
lakhansamani Apr 14, 2026
5e0646f
test(authz): add comprehensive authorization integration tests
lakhansamani Apr 14, 2026
7a94204
feat(authz): add authorization dashboard UI
lakhansamani Apr 14, 2026
fdc2b47
fix(authz): address security audit findings (H-1, H-2, C-2, C-3, M-3)
lakhansamani Apr 14, 2026
8eda5d0
chore(deps): upgrade pgx v5.5.4->v5.9.1, gorm postgres v1.5.4->v1.6.0
lakhansamani Apr 14, 2026
1967da2
feat(authz): add Prometheus metrics for check outcomes, unmatched che…
lakhansamani Apr 21, 2026
b557d4b
feat(authz): add per-key warn rate limiter for unmatched checks
lakhansamani Apr 21, 2026
ab1b12b
fix(authz): rewrite warn-limiter tests to avoid || short-circuit
lakhansamani Apr 21, 2026
7bb1fed
feat(authz): add in-process per-(resource,scope) unmatched counter
lakhansamani Apr 21, 2026
7199051
feat(authz): drop 'disabled' mode, wire metrics and rate-limited warn…
lakhansamani Apr 21, 2026
0f53c00
refactor(authz): use testSetupWithAuthzMode helper; clarify test-defa…
lakhansamani Apr 21, 2026
6d66677
feat(authz): default enforcement to permissive, migrate legacy 'disab…
lakhansamani Apr 21, 2026
bca16fd
refactor(authz): simplify NormalizeAuthzEnforcement and log startup p…
lakhansamani Apr 22, 2026
3f1c50b
fix(authz): pagination offset, fail-closed validation, typed valid-se…
lakhansamani Apr 22, 2026
5e77014
fix(authz): compensating rollback, typo-tolerant flag, string constan…
lakhansamani Apr 22, 2026
78ab37d
refactor(authz): drop phantom error return, startup probe timeout, sa…
lakhansamani Apr 22, 2026
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
101 changes: 101 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import (

"github.com/authorizerdev/authorizer/internal/audit"
"github.com/authorizerdev/authorizer/internal/authenticators"
"github.com/authorizerdev/authorizer/internal/authorization"
"github.com/authorizerdev/authorizer/internal/config"
"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/email"
"github.com/authorizerdev/authorizer/internal/events"
"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/http_handlers"
"github.com/authorizerdev/authorizer/internal/memory_store"
"github.com/authorizerdev/authorizer/internal/metrics"
Expand Down Expand Up @@ -74,6 +76,23 @@ var (
}
)

// legacyDisabledObserved is set when the operator passes the now-removed
// authorization-enforcement=disabled value. runRoot emits a one-time INFO log
// after the logger is configured. We cannot log from applyFlagDefaults because
// it runs before the logger is ready.
var legacyDisabledObserved bool

// legacyTypoObserved is set when the operator passes an
// authorization-enforcement value that is neither empty, "permissive",
// "enforcing" (any case), nor the legacy "disabled". runRoot emits a warning
// after the logger is configured so operators notice fat-fingered flags
// instead of being silently demoted to "permissive".
var legacyTypoObserved bool

// rawAuthzEnforcement preserves the operator-supplied --authorization-enforcement
// value before normalization, so runRoot can echo it back in the typo warning.
var rawAuthzEnforcement string

func init() {
f := RootCmd.Flags()

Expand Down Expand Up @@ -235,6 +254,12 @@ func init() {
// Back-channel logout (OIDC BCL 1.0)
f.StringVar(&rootArgs.config.BackchannelLogoutURI, "backchannel-logout-uri", "", "URL to POST a signed logout_token to when users log out successfully. Leave empty (default) to disable back-channel logout notifications. See OIDC Back-Channel Logout 1.0.")

// Fine-grained authorization flags
f.StringVar(&rootArgs.config.AuthorizationEnforcement, "authorization-enforcement", "permissive", "Authorization enforcement mode: permissive (default) or enforcing")
f.Int64Var(&rootArgs.config.AuthorizationCacheTTL, "authorization-cache-ttl", 300, "Cache TTL in seconds for permission checks (0 to disable)")
f.BoolVar(&rootArgs.config.IncludePermissionsInToken, "include-permissions-in-token", false, "Include permissions in JWT access tokens")
f.BoolVar(&rootArgs.config.AuthorizationLogAllChecks, "authorization-log-all-checks", false, "Audit log all permission checks, not just denials")

// Deprecated flags
f.MarkDeprecated("database_url", "use --database-url instead")
f.MarkDeprecated("database_type", "use --database-type instead")
Expand Down Expand Up @@ -321,6 +346,43 @@ func applyFlagDefaults() {
if len(c.RobloxScopes) == 0 {
c.RobloxScopes = append([]string(nil), defaultRobloxScopes...)
}
rawEnforcement := c.AuthorizationEnforcement
rawAuthzEnforcement = rawEnforcement
c.AuthorizationEnforcement = NormalizeAuthzEnforcement(rawEnforcement)
trimmed := strings.TrimSpace(rawEnforcement)
switch {
case strings.EqualFold(trimmed, "disabled"):
// Remember the legacy input so runRoot can log the one-time migration
// notice after the logger is configured. We cannot log here because
// applyFlagDefaults runs before the logger is ready.
legacyDisabledObserved = true
case trimmed == "",
strings.EqualFold(trimmed, constants.AuthorizationEnforcementPermissive),
strings.EqualFold(trimmed, constants.AuthorizationEnforcementEnforcing):
// Canonical input (case-insensitive) or unset; nothing to flag.
default:
// Anything else is a typo or unknown value. Surface it as a warning
// in runRoot so operators see their fat-fingered flag instead of
// being silently demoted to permissive.
legacyTypoObserved = true
}
}

// NormalizeAuthzEnforcement returns the canonical enforcement mode for the given input.
// - "enforcing" (case-insensitive, whitespace-tolerant) maps to "enforcing".
// - "" (empty), "permissive" (any case), "disabled" (legacy), and any
// unrecognized value map to "permissive" — the new safe default.
//
// Callers (applyFlagDefaults / runRoot) are responsible for emitting the
// legacy-migration notice for "disabled" (via legacyDisabledObserved) and a
// typo warning for unrecognized input (via legacyTypoObserved) after the
// logger is configured.
func NormalizeAuthzEnforcement(v string) string {
trimmed := strings.TrimSpace(v)
if strings.EqualFold(trimmed, constants.AuthorizationEnforcementEnforcing) {
return constants.AuthorizationEnforcementEnforcing
}
return constants.AuthorizationEnforcementPermissive
}

// Run the service
Expand Down Expand Up @@ -455,6 +517,44 @@ func runRoot(c *cobra.Command, args []string) {
}
defer rateLimitProvider.Close()

// Authorization provider
authorizationProvider, err := authorization.New(
&authorization.Config{
Enforcement: rootArgs.config.AuthorizationEnforcement,
CacheTTL: rootArgs.config.AuthorizationCacheTTL,
},
&authorization.Dependencies{
Log: &log,
StorageProvider: storageProvider,
},
)
if err != nil {
log.Fatal().Err(err).Msg("failed to create authorization provider")
}
if legacyDisabledObserved {
log.Info().Msg("authz: 'disabled' is no longer a supported enforcement mode; migrated to 'permissive'. CheckPermission calls with no matching permission will return ALLOWED and log authz.unmatched=true. Set --authorization-enforcement=enforcing once permissions are seeded.")
}

switch rootArgs.config.AuthorizationEnforcement {
case constants.AuthorizationEnforcementEnforcing:
// Check once at startup whether any permissions exist. If zero, emit a
// loud warn so operators don't lock themselves out in prod. Bounded
// context prevents a hung DB at boot from blocking startup indefinitely.
probeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, pr, lerr := storageProvider.ListPermissions(probeCtx, &model.Pagination{Limit: 1, Page: 1})
cancel()
switch {
case lerr != nil:
log.Warn().Err(lerr).Msg("authz: failed to probe permission count at startup; enforcing mode active")
case pr != nil && pr.Total == 0:
log.Warn().Msg("authz mode=enforcing but 0 permissions configured — all check_permission calls will DENY. Seed permissions or switch to --authorization-enforcement=permissive.")
default:
log.Info().Msg("authz mode=enforcing: unmatched CheckPermission calls will be DENIED.")
}
default:
log.Info().Msg("authz mode=permissive: unmatched CheckPermission calls will be ALLOWED and logged with authz.unmatched=true.")
}

// SMS provider
smsProvider, err := sms.New(&rootArgs.config, &sms.Dependencies{
Log: &log,
Expand Down Expand Up @@ -505,6 +605,7 @@ func runRoot(c *cobra.Command, args []string) {
TokenProvider: tokenProvider,
OAuthProvider: oauthProvider,
RateLimitProvider: rateLimitProvider,
AuthorizationProvider: authorizationProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create http provider")
Expand Down
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ require (
golang.org/x/time v0.15.0
gopkg.in/mail.v2 v2.3.1
gorm.io/driver/mysql v1.5.2
gorm.io/driver/postgres v1.5.4
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.5.2
gorm.io/gorm v1.25.5
gorm.io/gorm v1.25.10
)

require (
Expand Down Expand Up @@ -88,13 +88,14 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.5.4 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/libsql/libsql-client-go v0.0.0-20231026052543-fce76c0f39a7 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20230802215326-5cb5bb604475 // indirect
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8=
github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
Expand Down Expand Up @@ -481,14 +481,14 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlserver v1.5.2 h1:+o4RQ8w1ohPbADhFqDxeeZnSWjwOcBnxBckjTbcP4wk=
gorm.io/driver/sqlserver v1.5.2/go.mod h1:gaKF0MO0cfTq9Q3/XhkowSw4g6nIwHPGAs4hzKCmvBo=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2-0.20230610234218-206613868439/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
Expand Down
167 changes: 167 additions & 0 deletions internal/authorization/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package authorization

import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
)

// cache is a local in-memory cache with TTL support.
// It uses sync.Map for concurrent access and tracks per-key expiry.
// A distributed cache (via memory_store) will be layered on top in Phase 7.
type cache struct {
ttl time.Duration
data sync.Map
expiryMap sync.Map
counters sync.Map // counter key string -> *int64 (atomic-incremented)
// validSets holds membership-style caches (known resource names, known scope names).
// Stored separately from .data so string-valued entries never collide, and so
// the typed map lookup is O(1) without string parsing.
// A zero-length set is a valid cached value meaning "DB was reachable and empty".
validSets sync.Map // cache key -> map[string]struct{}
}

// newCache creates a new local cache. If ttlSeconds is 0, caching is disabled.
func newCache(ttlSeconds int64) *cache {
return &cache{
ttl: time.Duration(ttlSeconds) * time.Second,
}
}

// enabled returns true if caching is active (TTL > 0).
func (c *cache) enabled() bool {
return c.ttl > 0
}

// get retrieves a cached value by key. Returns the value and whether the key
// was found and still valid. Expired entries are lazily deleted on access.
// Both positive and negative cached results (authorization "true"/"false")
// follow the same lookup path, avoiding a cache-stampede on repeated
// deny evaluations for the same (principal, resource, scope).
func (c *cache) get(key string) (string, bool) {
if !c.enabled() {
return "", false
}

expiry, ok := c.expiryMap.Load(key)
if !ok {
return "", false
}
if time.Now().After(expiry.(time.Time)) {
// Lazily evict expired entry.
c.data.Delete(key)
c.expiryMap.Delete(key)
return "", false
}

val, ok := c.data.Load(key)
if !ok {
return "", false
}
return val.(string), true
}

// set stores a value in the cache with the configured TTL.
// Both "true" and "false" values are cached (negative caching)
// to prevent cache stampede on non-existent resource:scope combos.
func (c *cache) set(key string, value string) {
if !c.enabled() {
return
}
c.data.Store(key, value)
c.expiryMap.Store(key, time.Now().Add(c.ttl))
}

// deleteByPrefix removes all cached entries whose key starts with the given prefix.
// Used when admin mutations change resources, scopes, or policies to invalidate
// all related cached decisions. Iterates both the string-valued data map and the
// typed validSets map so both storage tiers are wiped in lockstep.
func (c *cache) deleteByPrefix(prefix string) {
c.data.Range(func(key, _ any) bool {
if strings.HasPrefix(key.(string), prefix) {
c.data.Delete(key)
c.expiryMap.Delete(key)
}
return true
})
c.validSets.Range(func(key, _ any) bool {
if strings.HasPrefix(key.(string), prefix) {
c.validSets.Delete(key)
c.expiryMap.Delete(key)
}
return true
})
}

// getValidSet returns the cached membership set for the given key.
// The second return value reports whether the cache had an entry at all.
// Callers must not mutate the returned map.
func (c *cache) getValidSet(key string) (map[string]struct{}, bool) {
if !c.enabled() {
return nil, false
}
expiry, ok := c.expiryMap.Load(key)
if !ok {
return nil, false
}
if time.Now().After(expiry.(time.Time)) {
c.validSets.Delete(key)
c.expiryMap.Delete(key)
return nil, false
}
v, ok := c.validSets.Load(key)
if !ok {
return nil, false
}
return v.(map[string]struct{}), true
}

// setValidSet stores a membership set under the given key with the configured TTL.
func (c *cache) setValidSet(key string, set map[string]struct{}) {
if !c.enabled() {
return
}
c.validSets.Store(key, set)
c.expiryMap.Store(key, time.Now().Add(c.ttl))
}

// evalKey constructs a cache key for an authorization evaluation result.
func evalKey(principalID, resource, scope string) string {
return fmt.Sprintf("authz:eval:%s:%s:%s", principalID, resource, scope)
}

// validResourcesKey returns the cache key for the set of known resource names.
func validResourcesKey() string {
return "authz:valid_resources"
}

// validScopesKey returns the cache key for the set of known scope names.
func validScopesKey() string {
return "authz:valid_scopes"
}

// unmatchedCounterKey builds the map key for a (resource, scope) unmatched event.
func unmatchedCounterKey(resource, scope string) string {
return "authz:unmatched:" + resource + ":" + scope
}

// bumpUnmatched increments the unmatched-check counter for the given (resource, scope).
// Counters are in-process only; they are reset on restart. A future dashboard view
// reads them to surface "uncovered checks" to operators during rollout.
func (c *cache) bumpUnmatched(resource, scope string) {
key := unmatchedCounterKey(resource, scope)
v, _ := c.counters.LoadOrStore(key, new(int64))
atomic.AddInt64(v.(*int64), 1)
}

// unmatchedCount returns the current unmatched counter for the given (resource, scope).
// Returns 0 if the key has never been bumped.
func (c *cache) unmatchedCount(resource, scope string) int64 {
key := unmatchedCounterKey(resource, scope)
if v, ok := c.counters.Load(key); ok {
return atomic.LoadInt64(v.(*int64))
}
return 0
}
Loading