Documentation
¶
Index ¶
- Constants
- func IsIdentityVar(val string) bool
- func RBACMiddleware(policyJSON []byte) func(http.Handler) http.Handler
- func RBACMiddlewareWithPublic(policyJSON []byte, isPublic func(method, path string) bool) func(http.Handler) http.Handler
- type Condition
- type EvalContext
- type EvalResult
- type Policy
- func (p *Policy) Allows(role, resource, action string) bool
- func (p *Policy) DenyDetail(role, resource, action string) string
- func (p *Policy) Evaluate(evalCtx EvalContext, resource, action string) EvalResult
- func (p *Policy) HasPublicSurface() bool
- func (p *Policy) RoleCacheable(role string) bool
- func (p *Policy) UnmarshalJSON(data []byte) error
- type ResourcePermission
- type RolePolicy
- type RouteGrant
- type WhereCondition
Constants ¶
const PublicRoleName = "$public"
PublicRoleName is the reserved role the schema's `rbac.public` block compiles into (ADR-026, PUBLIC-SURFACE-S1). An anonymous request (no Authorization header, on an app whose schema declares the block) is evaluated AS this role through the one existing evaluator — same deny-by-default, same conditions, same field allowlists, on every surface. The name is not declarable in rbac.roles (schema.PublicRoleName mirrors it; a cross-pin test keeps the two constants identical), so the anonymous surface can only come from the block.
const TransactionRoute = "transaction"
TransactionRoute is the reserved single segment of the atomic multi-resource transaction endpoint (G4): POST /api/transaction. It is NOT a resource — the handler authorizes EACH operation in the batch against its own resource (so a restricted role may transact over the resources it is allowed). The middleware therefore passes this path through (no single-resource RBAC check here); per-op RBAC is enforced in the handler. A schema resource may not be named "transaction" (reserved at load), so this never shadows a real resource's policy.
Variables ¶
This section is empty.
Functions ¶
func IsIdentityVar ¶ added in v0.1.10
IsIdentityVar reports whether a condition `val` names the caller's identity (the two variables the grammar accepts — any other `$…` is a load error, ENG-20). The single predicate the evaluator and the write rule share.
func RBACMiddleware ¶
RBACMiddleware returns a chi-compatible middleware that enforces the given policy JSON. It reads X-User-Role and X-User-ID from request headers, maps the HTTP method to a CRUD action, and injects the EvalResult into context. Requests to paths outside /api/ are passed through without enforcement.
func RBACMiddlewareWithPublic ¶
func RBACMiddlewareWithPublic(policyJSON []byte, isPublic func(method, path string) bool) func(http.Handler) http.Handler
RBACMiddlewareWithPublic is RBACMiddleware plus an exact-match pass-through for the custom routes explicitly registered as Public (appximo.Route {Public: true}): an anonymous request has no role, so path-based enforcement would deny-by-default every public route. The pass-through injects NO EvalResult — inside the handler, the Ctx's RBAC-aware helpers (Query/Insert/ Update) still evaluate the (empty) role themselves and fail closed; only deliberate anonymous logic (UnsafeTx, CreateUser) proceeds. Everything not matched by isPublic keeps full enforcement.
Types ¶
type Condition ¶
type Condition struct {
Field string `json:"field"`
Op string `json:"op"` // "eq", "neq", "in", etc.
Val string `json:"val"` // may be "$user_id", "$external_client_id", or a literal
}
Condition is a predicate evaluated at request time for row-level filtering.
type EvalContext ¶
EvalContext carries the identity of the caller for a single request.
func EvalContextFromRequest ¶
func EvalContextFromRequest(r *http.Request) EvalContext
EvalContextFromRequest resolves the caller identity for a request: the JWT claims injected by JWTMiddleware when present, else the X-User-* headers (so integration tests can run without a full JWT stack). This is the SINGLE identity-resolution used by both the route RBAC middleware AND the relation/subresource read checks (codegen.makeRelationRBAC), so every authorization path scopes by the same principal.
type EvalResult ¶
type EvalResult struct {
Allowed bool
AllowedFields []string // non-nil means restrict to these fields
Condition *WhereCondition // non-nil means append this WHERE clause
}
EvalResult is the outcome of a policy evaluation.
func EvalResultFromCtx ¶
func EvalResultFromCtx(ctx context.Context) *EvalResult
EvalResultFromCtx retrieves the EvalResult injected by RBACMiddleware. Returns nil if the middleware was not applied or the path was not enforced.
type Policy ¶
type Policy struct {
Roles map[string]RolePolicy `json:"roles"`
}
Policy holds all role definitions for a tenant.
func (*Policy) Allows ¶
Allows reports whether role may perform action on resource. Conditions are not evaluated here — use Evaluate for that.
func (*Policy) DenyDetail ¶
DenyDetail returns the operator-facing explanation of a deny: whether the caller's role is not declared by any schema role at all, or is declared but lacks the grant. SERVER LOG ONLY — deliberately asymmetric (ENG-27):
- The RESPONSE stays the byte-identical `403 {"error":"forbidden"}` for both cases. Distinguishing them in the body would turn every endpoint into an enumeration oracle over the schema's role namespace (an attacker minting tokens could probe which role names exist).
- The LOG gains the distinction, because that is where the operator looks and the attacker cannot. Before this, a token carrying a typo'd or forged role produced a deny indistinguishable from a legitimate one anywhere — not the engine log, not the access log, not the trace.
Echoing the role name in the log leaks nothing to the caller (they hold the JWT; its claims are base64, not encrypted). Same family as SEC-5: a defence must not leak through its own error channel.
func (*Policy) Evaluate ¶
func (p *Policy) Evaluate(evalCtx EvalContext, resource, action string) EvalResult
Evaluate determines whether evalCtx.Role may perform action on resource, resolves any dynamic variables in the applicable Condition, and returns the full result. The condition + field allowlist returned are ALWAYS the ones belonging to THIS resource: in the legacy form the role-global ones, in the per-resource form the matched resource's own — so every operation (read/create/update/delete/ aggregate, on REST and GraphQL, which all funnel through this one call) scopes by the correct resource's condition.
func (*Policy) HasPublicSurface ¶ added in v0.1.5
HasPublicSurface reports whether the policy declares an anonymous surface — the switch that lets the auth middleware admit tokenless /api requests as the public role instead of answering 401.
func (*Policy) RoleCacheable ¶
RoleCacheable reports whether the response cache may SHARE a role's responses across users. A role is cacheable ONLY if it injects no per-user row condition and no field allowlist anywhere — otherwise caching by role would let one user receive another's rows/fields. Covers BOTH forms: a legacy role with a global condition/allowlist is not cacheable, and a per-resource role is not cacheable if ANY of its resources carries a condition or a field allowlist (fail-safe).
func (*Policy) UnmarshalJSON ¶ added in v0.1.5
UnmarshalJSON folds the schema's `rbac.public` block into the reserved public role, so everything downstream — Evaluate, Allows, RoleCacheable, DenyDetail — sees ONE uniform role map and needs no second code path.
type ResourcePermission ¶
type ResourcePermission struct {
Actions []string `json:"actions"`
Conditions *Condition `json:"conditions,omitempty"`
ConditionActions []string `json:"condition_actions,omitempty"`
Fields []string `json:"fields,omitempty"`
}
ResourcePermission is one role's grant on ONE resource (G2): the actions allowed, an optional row-level Condition carrying that resource's OWN ownership column, an optional field allowlist, and ConditionActions to scope the condition to a subset of the actions (the "read all, write own" pattern — read unconditional, writes owner-scoped). An empty ConditionActions means the condition applies to ALL granted actions (the safe default — most restrictive).
type RolePolicy ¶
type RolePolicy struct {
Resources json.RawMessage `json:"resources,omitempty"`
Actions []string `json:"actions,omitempty"`
Conditions *Condition `json:"conditions,omitempty"`
FieldsAllow []string `json:"fields,omitempty"`
// Permissions is the per-resource form: resource name → its grant. Empty for a
// legacy role (omitempty keeps the marshalled legacy policy byte-identical).
Permissions map[string]ResourcePermission `json:"permissions,omitempty"`
// Routes grants CUSTOM-ROUTE segments (LIBRARY-GAPS-S1) — the virtual resource
// the middleware derives from a custom endpoint's first /api/ segment. Mirrors
// schema.RolePolicy.Routes so the schema→rbac.Policy JSON round-trip is lossless.
// Orthogonal to Resources/Permissions (different namespace: registered endpoints,
// not tables) and carries NO condition or field allowlist — a virtual segment has
// no rows. Empty for every pre-S1 role, so the evaluation path is unchanged.
Routes map[string]RouteGrant `json:"routes,omitempty"`
}
RolePolicy defines what a role can do. Resources is json.RawMessage because it can be the string "*" or a []string.
A role is expressed in ONE of two mutually-exclusive forms (G2):
- Role-global (legacy): Resources + Actions + (optional) Conditions/FieldsAllow. The single Conditions/FieldsAllow apply to EVERY listed resource. Behaviour is unchanged from before per-resource permissions existed.
- Per-resource (Permissions): each resource carries its OWN actions, condition and field allowlist — so one role can scope `workspace_id` on one resource and `conversation_id` on another (workspace/participation/owner scoping). When Permissions is non-empty it is the SOLE source of truth: a resource absent from the map is denied (deny-by-default). The two forms never mix (schema validation rejects a role that declares both).
type RouteGrant ¶
type RouteGrant struct {
Actions []string `json:"actions"`
}
RouteGrant is one role's grant on one custom-route segment: the allowed actions, nothing else. See schema.RouteGrant / ADR-021.
type WhereCondition ¶
type WhereCondition struct {
Field string
Op string
Value string // dynamic variables already substituted
// Identity is true when the declared `val` was a principal variable
// ($user_id / $external_client_id) — i.e. the column carries WHO OWNS the
// row, not a literal visibility filter such as status = "published". The
// write path treats an identity-bound column as server-owned on create
// AND update (codegen.EnforceCreateRBAC / EnforceUpdateRBAC,
// MOTOR-AUTORIZACION-S1); a literal condition only scopes reads and the
// WHERE of writes, so a moderator scoped to pending rows may still move
// one to approved.
Identity bool
}
WhereCondition is a resolved predicate ready to be appended to SQL.