Help us shape the future of Go! Take the 2026 Go Developer Survey

ds-go-iam-middleware

module
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: Apache-2.0

README

ds-go-iam-middleware

Build Go Report Card codecov GitHub release License

Echo middleware that authorizes requests against the IAM service.

Policy semantics live in ds-go-policy. This module resolves the principal, fetches and caches their policies, and exposes the decision to handlers through context.Context.

Wiring

import (
    "github.com/grasp-labs/ds-go-iam-middleware/adapter/principal"
    "github.com/grasp-labs/ds-go-iam-middleware/iam"
)

authz, err := iam.New(iam.Config{
    Fetcher:         &iam.HTTPFetcher{BaseURL: cfg.IAMURL},
    Cache:           app.Cache,       // your *bigcache.BigCache
    CacheLifeWindow: cacheLifeWindow, // the LifeWindow app.Cache was built with
    Principal:       principal.ResolvePrincipal,
})
if err != nil {
    return err
}
e.Use(authz.Handler())

Principal is func(context.Context) (iam.Principal, error) — read the ID and tenant from whatever your auth middleware already put in the context. This package never validates credentials.

ServiceID is an optional opt-in: set it in Config (e.g. ServiceID: "file") and compilation keeps actions for this service or *, while Constrain keeps allow and deny resource patterns for this service or *. The file service compiles file:getFile but drops state:getJobs. It sits above the fetcher seam, so it works for either policy source; left unset, the whole set is unchanged.

CacheLifeWindow is required and must be at least MaxStale (default 30m). Serving through an IAM outage reads cache entries up to MaxStale old, and a cache that evicts sooner would silently shorten that tolerance to its own window — so New refuses the mismatch at startup instead. Pass the same constant your cache was built from; if your API's cache keeps the common 10-minute window, either raise it to 30m or set MaxStale to 10m deliberately.

Handler captures the request's Authorization header and forwards it verbatim when the principal's policies are fetched, so the IAM API's own tenant scoping applies and the service configures no credentials for this middleware.

With ds-go-echo-middleware v3, use principal.ResolvePrincipal as shown above. It reads the normalized identity from standard context.Context through requestctx.GetPrincipal, preserves the subject verbatim, and converts the tenant UUID to a string. A missing identity, empty subject, or zero tenant returns iam.ErrNoPrincipal. It reads only the normalized principal; legacy claims alone do not satisfy it.

The adapter does not authenticate requests or fetch policies. Order the stack accordingly: request id, authentication, then authz.Handler(). Services using another authentication implementation can continue supplying their own Config.Principal callback.

Handlers

resource, err := crn.Build(tenantID, ownerID, "file", "", "file", path)
if err != nil {
    return httperror.BadRequest(err)
}

d := iam.Decide(ctx, iam.Request{
    Action:   "file:getFile",
    Resource: resource,
    Context:  map[string]string{"owner_id": ownerID},
})

switch {
case errors.Is(d.Err, iam.ErrNoPrincipal), errors.Is(d.Err, iam.ErrTokenRejected):
    return httperror.Unauthorized()
case errors.Is(d.Err, iam.ErrNoMiddleware), errors.Is(d.Err, iam.ErrPrincipalRejected):
    return httperror.Internal(d.Err)
case d.Err != nil:
    return httperror.Unavailable(d.Err)
case !d.Allowed:
    return httperror.Forbidden(d.Reason) // matched Sid, or "implicit deny"
}

Decide returns no error, so no error path can be mistaken for permission.

List endpoints

A list or search has no concrete resource to point Decide at, and looping it over rows checks only what the query already returned. Constrain is the list-shaped half: it reduces the policy set to the allow/deny patterns that survive for the action, and an adapter from ds-go-policy turns them into a filter the storage layer enforces.

import "github.com/grasp-labs/ds-go-policy/adapter/sqlfilter"

cons, err := iam.Constrain(ctx, "file:listFiles", nil)
if err != nil {
    return mapIAMError(err) // same buckets as a Decide error; do not run the query
}

where, args, err := sqlfilter.Where(cons, sqlfilter.Mapping{
    Tenant:     "tenant_id",
    Type:       "type",
    Resource:   sqlfilter.ResourceColumn{Path: "path"},
    Conditions: map[string]string{"status": "status"}, // row attribute -> column
})
if err != nil {
    return httperror.Internal(err) // pattern the adapter cannot express: fail closed
}

db.Where(where, args...).Find(&files)

Unlike Decide, Constrain returns an error — a filter has no fail-closed zero value, and an unresolved policy set must abort the query rather than return an empty (or worse, unfiltered) result. Point checks and list filters share one matcher in ds-go-policy, so they can never disagree.

The buckets matter more than the sentinels. A request that could not be decided must not read as a denial: 403 says "you may not", 503 says "we could not tell", and a caller retrying the first forever is a bug you will not see in the logs.

Error Status Meaning
ErrAccessDenied 403 The guard or converter refused access.
ErrNoPrincipal 401 The request was never identified.
ErrTokenRejected 401 IAM refused the forwarded token — usually expired since our own auth check.
ErrNoMiddleware 500 Decide ran on a route that skipped Handler. A wiring bug.
ErrPrincipalRejected 500 IAM will not accept the principal id. Permanent; retrying cannot fix it.
ErrPolicySourceUnavailable 503 Transport failure, 429, or 5xx — and no cached set young enough to serve stale.
ErrPolicySetMismatch 503 IAM answered with another principal's set.
ErrPolicyResolution 503 fallback Decide and Constrain wrap policy fetch, decode, and compile failures. Check the specific errors above first; causes remain available through errors.Is and errors.As.

Declarative guards

Guard[Result] binds an action to point checks and a service-owned converter. Require(ctx, req) calls Decide with the guard's action, preserving other request fields. It returns nil for an allow, wraps ErrAccessDenied for a denial, and returns decision errors unchanged. It does not use Convert.

Constrain(ctx) calls Constrain with no request attributes, then passes the complete constraints to Convert. The converter enforces the allow/deny patterns and produces the service's query scope or other result, including when no allow matches.

Services can supply their own converters and specialize the guard in their authz package with type Guard = iam.Guard[repo.Scope].

Set Action and, for Constrain, a non-nil Convert. Do not modify shared guards. A policy resolution failure skips conversion and returns the result's zero value and the error from Constrain. Converter results and errors pass through unchanged. Use Constrain directly when request attributes are needed; keep checks on proposed write values and service-specific tracing in the service.

GORM scopes

adapter/gormscope provides the shared SQL-to-GORM converter. The service owns the sqlfilter.Mapping, including its resource columns, condition allowlist, and publication rules:

import (
    "github.com/grasp-labs/ds-go-iam-middleware/adapter/gormscope"
    "github.com/grasp-labs/ds-go-iam-middleware/iam"
)

var listGuard = iam.Guard[gormscope.Scope]{
    Action:  "config:listDataset",
    Convert: gormscope.Via(datasetMapping),
}

scope, err := listGuard.Constrain(ctx)
if err != nil {
    return err // map errors.Is(err, iam.ErrAccessDenied) to 403
}
db.Scopes(scope).Find(&datasets)

Scope is an alias of func(*gorm.DB) *gorm.DB. Services can use the same alias for their repository scopes. Apply the scope to counts and writes as well as reads, alongside any additional service restrictions.

For a service that assembles its own predicate, Bind applies the completed SQL to the supplied query:

scope, err := gormscope.Bind(where, args...)

Check SQL generation errors before calling Bind. SQL must come from trusted code; values go through the heterogeneous args ...any. Both helpers group the predicate so additional filters cannot widen an OR grant. They return iam.ErrAccessDenied for the closed clause identified by sqlfilter.IsClosed. Empty SQL is rejected as invalid input, and translation errors pass through unchanged. Bind does not parse or validate arbitrary SQL.

Custom converters and point-check wrappers can use the same iam.ErrAccessDenied for permission denials. Policy-loading failures wrap ErrPolicyResolution and their original cause. Ordinary Decide denials still have Allowed: false and Err: nil.

How it works

Two cache layers. Cache (bigcache) holds raw policy JSON, keyed by principal. An in-process map holds *engine.Compiled, keyed by a content hash of the response. The second layer is not an optimisation: Cache stores bytes, so without it every cache hit would re-run Unmarshal and engine.Compile. The hash rather than the ETag is deliberate — that map is shared by every principal in the process, and equal bytes are safe to share where equal validators would not be.

Lazy. Handler does no I/O. Policies load on the first Decide or Constrain, so routes that authorize nothing cost nothing and survive an IAM outage.

Deduplicated. Cold lookups for the same principal collapse via singleflight, detached from the request so a client disconnect cannot cancel a fetch others are waiting on.

Fail closed. No principal, a token IAM refuses, IAM unreachable, policies that will not compile — all give Allowed: false with Err set. There is no configuration that turns an unresolved policy set into an allow.

Fail static. When the IAM service is unreachable, a set fetched less than MaxStale ago (default 30m) keeps answering: availability degrades before correctness does, and only for principals who already held a set. A failed revalidation starts a short cooldown so an outage costs one fetch round trip per principal per cooldown, not one per request. Past MaxStale the outage surfaces as ErrPolicySourceUnavailable — a 503, not a 403. A 401 or 422 from IAM is an answer about the caller, never papered over with a stale set.

Checked. The policy set echoes the principal it belongs to, and a set naming anyone else is refused rather than compiled.

Evictable. Evict(tenantID, principalID) drops one cached set so the next request resolves it afresh — for a service that observes its own permission-changing writes (ds-iam), calling it synchronously means zero revocation lag for those writes.

Revocation lag equals TTL (default 60s) while IAM is reachable. During an outage it can grow to MaxStale for a principal whose set was already cached — the price of staying up.

Testing handlers

ctx, err := iam.NewTestContext(context.Background(),
    iam.Principal{ID: "user-1", TenantID: tenantID}, policyJSON)

License

Apache 2.0

Directories

Path Synopsis
adapter
gormscope
Package gormscope converts IAM constraints or completed SQL predicates into GORM scopes.
Package gormscope converts IAM constraints or completed SQL predicates into GORM scopes.
principal
Package principal adapts authenticated identities from request contexts for IAM.
Package principal adapts authenticated identities from request contexts for IAM.
Package iam authorizes Echo requests against policies held by the IAM service.
Package iam authorizes Echo requests against policies held by the IAM service.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL