Documentation
¶
Overview ¶
Package rbac decides authorization from stored policy rules and keeps those rules in step with the records they are derived from.
Three rule kinds carry everything the package stores. A permission, (tenant, role, object, action, effect), is what a role may reach. An assignment, (subject, role, tenant), is who holds a role in one tenant. A system assignment, (subject, role), is who holds a role above every tenant, which is how the built-in root subject stays reachable in a deployment with no tenants configured at all.
Reads answer from memory: the two role branches from the role graph, and everything a policy decides from the decision index, which is rebuilt from the in-memory policy set whenever that set changes. Writes do not: they go through mutate, which drives storage and memory as two halves so that a policy change rolls back with the transaction that caused it. Every write is a mutation of the one in-memory set, so nothing can change what is decided from behind mutate's back.
One process decides from its own memory ¶
A decision is answered from the policy set this process holds, and only the process that performed a write updates that set. Nothing tells one process about a write another one made — what bounds the difference is the schedule: every process rebuilds its set from storage once per reloadInterval, for as long as it runs.
Every write goes through mutate, so a process's own writes are always current in its own memory. Three things reach past that, and each is bounded by one interval at worst, repaired sooner where something noticed:
- A write another process made: a revocation applied on one replica reaches the others no later than their next reconciliation.
- Storage changed by something that is not a process at all: a manual repair, a restore, another tool writing the policy table. A removal that touches the affected rules surfaces it early — its stored and in-memory row counts disagree, and the disagreement is answered by reloading.
- A recovery that could not read storage back. The process then serves decisions that disagree with what is stored, publishes that state through the authz policy divergence gauge, logs it at error level, and retries on a loop tighter than the schedule: this staleness is known rather than possible, and known-wrong time is worth more urgency.
TODO: cut the cross-process delay under the interval with a published notification on top of the reconciliation. The intended carrier is dcache's replicated cache: a policy-version entry written on every mutate, carried to every replica's local map by its Kafka-backed broadcast, watched there for nearly nothing, and feeding the same ReloadPolicies entry point. It can only ever sit on top — delivery is at-most-once, and it rests on a component the framework treats as optional (Kafka) — so the reconciliation stays underneath, and a missed notification costs latency rather than correctness.
Storage decides what two rules are, and does not always agree ¶
A rule's identity in memory is its exact bytes, because the policy set is keyed with a Go map. Its identity in storage is whatever the column's collation says, and the framework supports backends that disagree: a case-insensitive collation, which is the default in some MySQL installations, treats two rules differing only in case as one, while memory treats them as two.
Where they disagree, the two halves of a write part company. An exact delete removes a rule the caller did not name and leaves the named one in memory — that half is caught, because the two sides then count different rules and the disagreement is answered by reloading. An insert whose rule collides with a stored one under that collation is dropped by the on-conflict clause and kept in memory, and that half stays silent: an insert has no row count that means the same thing on every backend, so there is nothing to compare.
TODO: stop depending on storage to decide rule identity, in a way that holds for every supported backend. Nothing here can require a collation, since the database is not the framework's to configure.
Index ¶
Constants ¶
const ( // UserRoot is the built-in root user's id (subject), not a role name. UserRoot = "root" // RoleAdmin is the tenant-scoped admin role, granted via // g(subject, role, tenant) by a role binding naming it, since it has no // row in the roles table. Authorization grants this role unconditional // access to every object/action inside the tenant, bypassing explicit // permission policies entirely. RoleAdmin = "admin" // SystemRoleRoot is the system-level super-admin role, granted via // g2(subject, role) outside any tenant. UserRoot is assigned this role by // default during RBAC Init. SystemRoleRoot = "system_root" // RoleAuthenticated is the implicit role every authenticated subject // carries. No grouping rule ever assigns it, and no role may claim it as // an ID: authorization grants policies written for it without consulting // role membership or tenant, so they reach subjects that hold no role at // all. // // Use it for routes that answer only about the caller and already narrow // their result to what the caller may see. It is not "public": // unauthenticated requests are rejected before authorization runs. RoleAuthenticated = "authenticated" )
The subjects and roles the authorization knows by name.
const ( GrantSourceSystemRoot = types.GrantSourceSystemRoot GrantSourceTenantAdmin = types.GrantSourceTenantAdmin GrantSourceAuthenticated = types.GrantSourceAuthenticated GrantSourceRole = types.GrantSourceRole )
The kinds of rule that allow a request.
const ( DenyReasonUnauthenticated = types.DenyReasonUnauthenticated DenyReasonNoRole = types.DenyReasonNoRole DenyReasonNoPolicy = types.DenyReasonNoPolicy DenyReasonNotInitialized = types.DenyReasonNotInitialized )
The reasons a request is not allowed.
const ( EffectAllow = types.EffectAllow EffectDeny = types.EffectDeny )
The effects of an authorization policy.
Variables ¶
var ErrEmptyPolicyValue = errors.New("rbac: policy mutation carries an empty value")
ErrEmptyPolicyValue reports a mutation carrying an empty value where an identifier belongs.
An empty value does not narrow a request, it widens it. Storage and the in-memory set agree that an empty filter field matches anything, so a filtered delete built from an empty argument removes every rule of its kind in the deployment instead of none — revoking a role for one subject would revoke it for all of them. An empty value inside a rule is the mirror image: the row stores fewer meaningful columns than its kind declares, and the next load of the policy set rejects it and takes the whole set down with it.
Neither outcome can be recovered by guessing what was meant, and both are silent at the call site, so a mutation carrying an empty value is refused before anything is written.
var ErrRBACDisabled = errors.New("rbac: authorization is not initialized")
ErrRBACDisabled reports a policy write made in a process that holds no policy set to write it to.
A write has nowhere to go before Init has installed a policy set: there is no in-memory set to change and no adapter to store the change through. Reporting success would tell the caller its change is in force — a role created through the API answering success with not one policy row behind it — and nothing downstream can tell that apart from a change that landed, because the records the rules are derived from were written either way.
Reads are answered instead of refused. A denial and an empty role set are both true of a process deciding from no policies, so answering them states the situation rather than hiding it.
var ErrReservedRole = errors.New("rbac: role is reserved by the authorization matcher")
ErrReservedRole reports a permission written against a role the matcher reads as something other than an ordinary role.
A policy stored for the implicit authenticated role is matched without a role membership check and without a tenant check, so it allows every subject that can log in, in every tenant. Written through an ordinary role call it would turn one tenant's permission set into a grant across the whole deployment, while reading in the records it came from as a role like any other.
SetPermissionsForAuthenticated writes that rule deliberately, and refusing it here is what leaves that method as the only way to write it. Removals are not refused: whatever is already stored has to be removable.
var ErrSubjectIsRole = errors.New("rbac: subject and role share one name, the assignment would never take effect")
ErrSubjectIsRole reports an assignment whose subject and role share one name.
Such an assignment can never take effect: the matcher and the role-graph branches both refuse a self-match, precisely so that a subject named like a role is not handed that role. Storing the rule anyway would report success for a grant that decides nothing, and nothing downstream could tell that apart from a grant that works.
Functions ¶
func Enforce ¶
func Enforce(ctx context.Context, subject Subject, obj, act string) (context.Context, string, error)
Enforce decides whether subject may perform act on obj — the HTTP method and the path or route the action is served at — and logs the decision (see logGrant, logDeny and logFailure) under the trace id ctx carries, the one both listeners stamp on it. An anonymous subject is refused before any decision. The tenant is subject's, tenant.Default when it has none, and comes back as the one the decision was made in, for the transport to publish onward; it is trusted twice over — the decision is made in it and every tenant-scoped row the action then reads or writes is scoped to it — so a transport must only ever establish it from something the deployment vouches for, never from client input passed through as it stands. A subject allowed as system_root was not authorized in any one tenant, so the returned context reaches across tenants (see tenant.Across), which keeps the reach of the data equal to the reach of the grant.
A refusal is a service error carrying the status and message the client is answered with: 403 "permission denied" for an anonymous subject and a denied one, 500 "authorization unavailable" when the decision could not be made, which is this server's failure and not the client's — nothing about the request is wrong, and the client cannot change anything to make the decision reachable.
func Init ¶
func Init() error
Init loads the stored policy set and installs it, when RBAC is enabled.
The set is read and made whole before it is published, and published together with its adapter and its derived state. A reader reaches RBAC as soon as the set is assigned, so assigning the pieces as they are produced would hand out a policy set whose writes have no adapter to go through, or decisions with no index to answer from.
func RBAC ¶
RBAC returns the authorization entry point this process decides from.
Before Init has installed a policy set — RBAC disabled, or a process that never bootstrapped — it answers with noop, which denies every request and refuses every write.
The two package variables are read together under the lock they are installed under. Read outside it, a caller could be handed a policy set whose adapter has not been assigned yet, and the first write through it would dereference a nil adapter.
Types ¶
type DenyReason ¶
type DenyReason = types.DenyReason
DenyReason names why a request was not allowed: no authenticated subject, a subject holding no role in the request tenant, a subject whose roles carry no permission covering the object and action, or a process holding no policy set at all.
type GrantSource ¶
type GrantSource = types.GrantSource
GrantSource names the kind of rule that allowed a request: the system root role, the tenant admin role, a policy written for the implicit authenticated role, or a policy granted to a role the subject holds in the request tenant. An allowed request has exactly one source, the strongest of the rules it satisfies.