Documentation
¶
Overview ¶
audience.go is the gate half of this package.
Everything else here is additive: a Binding grants a role, and resolving an identity means unioning every grant that matches it. That shape cannot express "this executor is available to these two groups and nobody else", because the permission being restricted — executor.manage, or simply the right to run work somewhere — is one the caller already holds fleet-wide. Adding grants cannot take it away, and narrowing a grant to one executor (Binding.Executor) restricts *that grant*, not the resource: every identity whose unscoped binding already covers the executor keeps reaching it.
So an audience is a separate predicate, and it composes with the role ladder by intersection rather than union — see pkg/ui/executoraudience.go, which is the only enforcement point that matters. This file owns just the question "does this identity appear in this list", because the answer has to agree exactly with Binding.matches: an admin who writes a group path their IdP shows them expects it to work in both places, and two normalisers would eventually disagree about a leading slash or a letter case and produce a gate that admits someone the role model denies, or the reverse.
Package authz implements claim-based role-based access control for the cloop hub.
The model is deliberately small enough to audit by reading it:
Permission a single capability ("run.start", "executor.manage", …)
Role a named bundle of permissions (viewer < operator <
maintainer < admin)
Binding "identities carrying this claim value hold this role",
optionally narrowed to one project or one executor
Subject the claims cloop extracted from a validated ID token
Decision the resolved role + permission set for one (subject, scope)
Resolution is deny-by-default: a subject that matches no binding falls back to Config.DefaultRole, which is RoleNone (deny everything) unless an operator explicitly configures otherwise.
Precedence ¶
A binding applies to a request only if its scope is satisfied: a binding with Project set applies only to requests against that project, and a binding with Executor set only to requests against that executor. Applying bindings are then ranked by specificity:
tier 3 project + executor both pinned tier 2 executor pinned tier 1 project pinned tier 0 global (neither pinned)
The highest tier with any match wins outright, and within that tier the strongest role wins. A more specific binding therefore *overrides* a broader one rather than merging with it — which is what makes scoping useful in both directions: an operator can grant a global viewer maintainer rights on one project, and can equally hold a global maintainer down to viewer on a sensitive project.
Config.AdminEmails (the pre-RBAC admin list) participates as an ordinary global admin binding, so it keeps working untouched and a project-scoped binding can still narrow it.
Runtime bindings ¶
Bindings come from two layers. The configured layer above is deployed with the rest of the hub's YAML. The runtime layer (Config.Runtime, Task 20248) is rows an operator wrote during an incident, read live rather than at startup. Between the layers the rule is: deny wins, and the database overrides config. Resolve states it in full and the tests pin it.
This package has no dependencies beyond the standard library and knows nothing about HTTP or OIDC wire formats; pkg/oidcauth extracts claims into a Subject and pkg/ui enforces the Decision.
Index ¶
- Variables
- func Admits(members []AudienceMember, s *Subject) bool
- func RequiresFreshClaims(p Permission) bool
- type AudienceMember
- type Binding
- type ClaimKind
- type Config
- type Decision
- type Permission
- type Resolver
- func (r *Resolver) Configured() bool
- func (r *Resolver) DefaultRole() Role
- func (r *Resolver) DeniedBy(subject *Subject, scope Scope) *Binding
- func (r *Resolver) HasRuntimeBindings() bool
- func (r *Resolver) Resolve(subject *Subject, scope Scope) Decision
- func (r *Resolver) RuntimeBindings() []Binding
- func (r *Resolver) UnsatisfiableBindings(subject *Subject) []Binding
- type Role
- type RuntimeSource
- type Scope
- type Source
- type Subject
Constants ¶
This section is empty.
Variables ¶
var AllClaimKinds = []ClaimKind{ClaimGroup, ClaimRole, ClaimEmail, ClaimSub}
AllClaimKinds lists the claim kinds a binding may use.
var AllPermissions = []Permission{ PermProjectRead, PermProjectWrite, PermRunStart, PermRunStop, PermTaskMutate, PermExecutorRead, PermExecutorManage, PermSecretGrant, PermSecretRevoke, PermConfigWrite, PermAuditRead, PermUserManage, PermTokenAdmin, PermSessionAdmin, PermViewPrefs, PermSandboxAttach, PermSandboxAttachWrite, PermSecretRequest, PermSecretOwn, }
AllPermissions lists every real permission (PermPublic excluded) in a stable order. Used for config validation and for the permission set the frontend receives.
var AllRoles = []Role{RoleNone, RoleViewer, RoleOperator, RoleMaintainer, RoleAdmin}
AllRoles lists roles from weakest to strongest.
var GlobalScope = Scope{}
GlobalScope is the scope for actions that are not about one project or one executor.
Functions ¶
func Admits ¶ added in v0.0.3
func Admits(members []AudienceMember, s *Subject) bool
Admits reports whether s appears in the audience.
An *empty* audience admits everyone. That is the opt-in property the whole feature rests on: an executor nobody has restricted behaves exactly as it did before audiences existed, so enabling this code path is a no-op for every existing deployment, and the first entry an admin adds is the act that turns the gate on. Callers that need to distinguish "unrestricted" from "restricted to a list that happens to admit this caller" ask len(members) instead — the two are different sentences in an audit trail even though they permit the same request.
A nil subject is denied unless the audience is empty. A hub running without OIDC has no subjects at all, and there the audience is empty on every executor because there is no identity an admin could have named.
func RequiresFreshClaims ¶ added in v0.0.2
func RequiresFreshClaims(p Permission) bool
RequiresFreshClaims reports whether exercising p demands a claim set the identity provider has confirmed within the deployment's max_claim_age.
True for every permission an operator does not hold. PermPublic is false: it marks a route with no authorization at all, and a login page that contacted the IdP before rendering would be a denial of service with extra steps.
Types ¶
type AudienceMember ¶ added in v0.0.3
type AudienceMember struct {
// Kind is ClaimGroup, ClaimRole, ClaimEmail or ClaimSub. The storage layer
// and the UI speak of "user" and "group"; a "user" arrives here as either
// ClaimEmail or ClaimSub depending on what the admin typed, which is
// resolved by AudienceMemberFor.
Kind ClaimKind `json:"kind"`
// Value is the claim value to match, in whichever spelling the admin used.
Value string `json:"value"`
}
AudienceMember is one entry in an executor's allowlist.
Kind is deliberately the same ClaimKind the binding model uses rather than a private enum, so that the set of things an admin can name here can never drift from the set a role binding can name.
func AudienceMemberFor ¶ added in v0.0.3
func AudienceMemberFor(kind, value string) AudienceMember
AudienceMemberFor turns the two-way UI vocabulary ("user" or "group", plus a typed value) into the claim this package matches on.
A "user" is ambiguous at the point an admin types it: an IdP subject is an opaque identifier and an email is not, and which one they pasted is knowable only by looking. Guessing on the presence of "@" is the whole rule, and it is safe in both directions — an email compared as a subject fails closed, and a subject compared as an email fails closed too, because the comparison that would wrongly succeed is one where the two strings are equal anyway.
An unrecognised kind yields the zero AudienceMember, which Admits never matches. That is the direction a gate must fail in: a row this binary cannot interpret restricts more than intended rather than less.
type Binding ¶
type Binding struct {
// Claim selects what Value is compared against.
Claim ClaimKind
// Value is the claim value to match. Group and role values are
// compared case-insensitively after trimming whitespace and any
// leading "/" (Keycloak emits group paths as "/cloop-admins").
Value string
// Role is granted to identities matching Claim/Value within scope.
Role Role
// Project narrows the binding to one project, matched
// case-insensitively against either the project's registry name or its
// filesystem path. Empty means "every project".
Project string
// Executor narrows the binding to one executor ID. Empty means "every
// executor".
Executor string
// Deny turns the binding into a withdrawal of authority rather than a
// grant of it, and it is meaningful only on a runtime binding (Task
// 20248). Role is ignored when it is set.
//
// It is a separate field rather than Role=RoleNone because the two are
// ranked differently. Allows compete on specificity and then on strength,
// so a tier-0 "none" loses to a tier-0 "admin" — which is exactly the
// collision an emergency demotion would hit, since oidc.admin_emails
// produces tier-0 admin bindings. A Deny does not compete: see Resolve.
Deny bool
}
Binding maps one claim value to one role, optionally narrowed to a single project and/or a single executor. See the package comment for precedence.
func NormalizeBinding ¶ added in v0.0.2
NormalizeBinding canonicalizes and validates a binding the same way New does for configured ones.
Exported for the runtime layer, which does not go through New but must agree with it exactly on what a binding's identity is. If storage lowercased an email and resolution did not — or either kept Keycloak's leading "/" on a group path — an operator would write a deny against a value that never matches and get a confident success message for a binding that does nothing.
A Deny binding needs no role and is normalized to RoleNone; anything else is rejected on the same grounds as a configured binding.
type ClaimKind ¶
type ClaimKind string
ClaimKind selects which part of an identity a binding matches against.
const ( // ClaimGroup matches a value from the ID token's group claim // (`groups`, Keycloak's `realm_access.roles` group paths, etc.). ClaimGroup ClaimKind = "group" // ClaimRole matches a value from the ID token's role claim. ClaimRole ClaimKind = "role" // ClaimEmail matches the email claim, case-insensitively. ClaimEmail ClaimKind = "email" // ClaimSub matches the issuer subject exactly (case-sensitively — it // is an opaque identifier, not a human-readable name). Useful when the // IdP does not release email. ClaimSub ClaimKind = "sub" )
type Config ¶
type Config struct {
// DefaultRole applies to authenticated identities that match no
// binding. The zero value means RoleNone: deny by default.
DefaultRole Role
// Bindings are the configured claim→role mappings.
Bindings []Binding
// AdminEmails is the pre-RBAC admin list (oidc.admin_emails). Each
// entry becomes a global admin binding.
AdminEmails []string
// Runtime supplies bindings that are not configuration: rows an operator
// wrote at runtime to grant or, more importantly, withdraw authority
// during an incident (Task 20248). Nil on a hub with no such store, which
// is every hub that has never had an incident.
//
// It is an interface rather than a slice because the whole point is that
// these change while the process runs. The Resolver stays immutable — the
// reference is fixed at New — and the source owns its own freshness.
Runtime RuntimeSource
}
Config is the operator-supplied policy, mapped from oidc.role_mappings and oidc.default_role in .cloop/config.yaml.
type Decision ¶
type Decision struct {
// Role is the effective role, RoleNone when nothing granted.
Role Role
// Source explains where Role came from.
Source Source
// Scope is the scope the decision was resolved for.
Scope Scope
// Binding is a copy of the winning binding, nil when Source is not
// SourceBinding.
Binding *Binding
// SubjectLabel identifies the acting subject for audit records.
SubjectLabel string
// contains filtered or unexported fields
}
Decision is the resolved authority of one subject over one scope. The zero value denies everything, which is the safe failure mode if a caller ever forgets to populate it.
func AllowAll ¶
AllowAll builds a decision granting every permission, used for the two paths that intentionally bypass RBAC: OIDC disabled (single-tenant local use) and static-token automation. src records which.
func FromRoles ¶
FromRoles builds a Decision holding the union of the permissions granted by roles, for callers whose authority is carried directly rather than derived from claims — today, API tokens (see pkg/apitoken).
The union is computed explicitly instead of taking the strongest role's set. Today the ladder is cumulative so the two agree, but the union is the definition that stays correct if a future role is ever added off the ladder, and it cannot be wrong in the permissive direction: a permission ends up in the set only if some named role already grants it.
Decision.Role is reported as the strongest role present, purely so audit records and /api/me have a single name to show. Enforcement reads the permission set, never that label.
An empty or all-unknown role list yields a deny, which is what makes a malformed token inert rather than dangerous.
func Intersect ¶
Intersect returns the decision granting only what *both* inputs grant.
It exists for delegated credentials: a token minted on behalf of a user carries a role ceiling of its own, but must never outlive or outrank the authority of the person it was minted for. Resolving the owner's authority afresh on every request and intersecting it with the token's ceiling makes that a live property rather than a snapshot — a user demoted this morning loses the delegated access this afternoon, without anyone remembering to revoke anything.
The intersection is over *permission sets*, not over role ranks. Today the ladder is cumulative, so the two agree; computing it from the sets means the answer stays correct if a future role ever bundles permissions that are not a superset of the tier below. Role is reported as the weaker of the two labels, which is display and audit only — Allows reads the set.
An allow-all input (OIDC disabled, static token) contributes no restriction: intersecting with it yields the other side untouched, including any future permission neither side had to enumerate.
The result always keeps a's Source, SubjectLabel and Binding — a is the *acting* credential and b is the authority bounding it. Copying b's labels when b happens to rank lower would file every refusal of a leaked link under the name of the human it was minted for, which is precisely the case the owner binding exists to make visible: an operator reading the audit trail could not tell a stolen URL from that user's own browser being denied.
func (Decision) Allows ¶
func (d Decision) Allows(p Permission) bool
Allows reports whether the decision grants p. PermPublic is always allowed: it marks a route as needing no authorization at all.
func (Decision) Permissions ¶
func (d Decision) Permissions() []Permission
Permissions returns the granted permissions as a stable sorted slice. For an allow-all decision this is every known permission, so the frontend receives a concrete list either way.
type Permission ¶
type Permission string
Permission is a single capability a caller may hold. Values are stable wire strings: they appear in config files, in /api/me payloads the frontend gates on, and in audit records. Never rename one; add a new constant instead.
const ( // PermProjectRead is the right to see a project at all: its state, // tasks, steps, analytics, and event history. A caller without it is // told the project does not exist (404), never that it is forbidden. PermProjectRead Permission = "project.read" // PermProjectWrite is the right to change a project's identity and // lifecycle: goal, instructions, initialization, reset, deletion. PermProjectWrite Permission = "project.write" // PermRunStart is the right to start execution — the permission that // actually spends tokens and money. PermRunStart Permission = "run.start" // PermRunStop is the right to halt a running plan. Deliberately // separate from PermRunStart: stopping a runaway plan is a safety // action and is granted to operators who may not be able to start one. PermRunStop Permission = "run.stop" // PermTaskMutate is the right to add, edit, reorder, retag, or delete // tasks, and to apply AI-proposed plan changes. PermTaskMutate Permission = "task.mutate" // PermExecutorRead is the right to list the executor fleet and see the // host-execution policy. PermExecutorRead Permission = "executor.read" // PermExecutorManage is the right to enroll, revoke, cordon, drain, or // bind executors. Enrolling a device grants it the right to run project // workloads, so this is an administrator-grade permission. PermExecutorManage Permission = "executor.manage" // PermSecretGrant is the right to lease a credential (GitHub PAT, // kubeconfig, egress allowance) to an executor. PermSecretGrant Permission = "secret.grant" // PermSecretRevoke is the right to revoke an outstanding credential // lease before its TTL expires. PermSecretRevoke Permission = "secret.revoke" // PermConfigWrite is the right to change provider/model selection, // budgets, rate limits, timeouts, and the persistent run options. PermConfigWrite Permission = "config.write" // PermAuditRead is the right to read the tamper-evident audit trail and // to verify its hash chain. // // Deliberately not granted to maintainer. The trail records who leased // which credential to which executor, who was denied what, and every // privileged grant across the fleet — including the actions of people // more privileged than the reader. Handing it to the same role that // brokers credentials would also let an operator watch their own // oversight. It is granted to admin alone, which is what makes "a plain // member cannot read the trail" a property of the role ladder rather // than of any one route's wiring. PermAuditRead Permission = "audit.read" // PermUserManage is the right to administer access itself: role // bindings, sessions, and the identity integration. PermUserManage Permission = "user.manage" // PermTokenAdmin is the right to mint, list, and revoke the API tokens // that authenticate non-interactive callers (CI, scripts, edge devices). // // Admin-only, like PermUserManage, because minting a token *is* // provisioning an identity: the holder acts with the roles stamped into // it, without a browser, an IdP session, or a human behind it. Handing // that to maintainer would let the role that brokers credentials also // manufacture the callers that consume them. // // Holding it is necessary but not sufficient: the mint path additionally // refuses to issue a role the caller does not already hold, so this // permission grants the ability to *delegate* authority, never to // escalate it. See pkg/ui/tokens_api.go. PermTokenAdmin Permission = "token.admin" // PermSessionAdmin is the right to list every signed-in session and to // terminate one (Task 20176). // // Deliberately separate from PermUserManage, which grants authority over // role bindings and the identity integration. Ending a session is a // containment action — the thing an on-call operator does at 3am when a // laptop goes missing — and it changes nobody's standing rights, so it // should not require the ability to rewrite who is an admin. The narrower // grant is the one that will actually be used in an incident. // // It is still an oversight-grade permission: the session list names who is // signed in, from where, and on what, which is reconnaissance for anyone // who should not have it. Admin-only in the default ladder; a deployment // that wants an operator-on-call role binds it explicitly. // // Ending one's *own* sessions needs nothing: POST /api/session/logout-all // is authenticated but ungated, because a user closing their own sessions // is never an escalation. PermSessionAdmin Permission = "session.admin" // PermSandboxAttach is the right to open an interactive session inside a // running workload's sandbox — a read-only terminal (Task 20265). // // Its own permission, and granted to nobody below admin by default. Two // reasons, and neither is "shells are scary": // // First, it is not a read of the project. project.read delivers the // transcript the harness chose to emit; attach delivers the sandbox's // filesystem, its process table, its environment, and whatever the // workload happens to be holding at that instant. The lease broker // deliberately never exports a bare token, and the redaction path scrubs // the values it knows about — but a terminal is a general-purpose reader // and a credential nobody declared is a credential nobody scrubs. Folding // this into project.read would silently promote every viewer on the hub. // // Second, it is the one action in the product that reaches *through* the // isolation boundary the rest of it exists to maintain. A permission that // says so by name is what makes "who may enter a sandbox" answerable from // the role binding rather than from reading route code. // // Deny-by-default in the strong sense: it appears in no role's list except // admin's, which holds every permission. A deployment that wants an // on-call engineer to debug without administering the fleet binds it // explicitly — the same shape as PermSessionAdmin, and for the same reason. PermSandboxAttach Permission = "sandbox.attach" // PermSandboxAttachWrite is the right to *type* into such a session. // // Separate from PermSandboxAttach because observing and intervening are // genuinely different acts, and the interesting grant is the narrow one: an // operator diagnosing a hung task needs to look, and the reason to stop // there is that anything they type runs as the workload, with the // workload's credentials, inside a run whose output is about to be // attributed to an AI agent. A write session makes the task's transcript a // record of two authors, and the audit event is the only thing that will // ever say so. // // Holding it is necessary but not sufficient: a writable session also // requires PermSandboxAttach, because there is no such thing as typing into // a terminal one may not open. The hub checks both. PermSandboxAttachWrite Permission = "sandbox.attach.write" // PermSecretRequest is the right to *ask* for a credential — to file an // access request, list one's own, and withdraw one (Task 20271). // // Granted from operator up, which makes it the only permission in the // secret family below maintainer, and that asymmetry is the whole point. // Asking confers nothing: a request is a row with a justification on it, and // until somebody who holds PermSecretGrant approves it there is no // credential, no lease, and nothing an executor can use. What it replaces is // a conversation in a chat window — which is where over-broad grants come // from, because the person minting one is reconstructing a scope from a // sentence and a wider guess is always the cheaper one. // // Deliberately not granted to viewer. A request names a secret, a project // and a repository allowlist, so the ability to file one is the ability to // enumerate what exists by probing — and a viewer is the role that cannot // start a run, which is to say the role with nothing to spend a credential // on. // // Approving is a different permission and stays where it was: PermSecretGrant // at maintainer. Holding both does not let one person do both — the broker // refuses an approval by the identity that filed the request, which is the // check that has to exist because on a small team those are frequently the // same person. PermSecretRequest Permission = "secret.request" // PermSecretOwn is the right to keep credentials of one's own on the hub: // to mint a personal secret, list the personal secrets one owns, grant one // to one's own work, and destroy it (Task 20275). // // Granted from operator up, alongside PermSecretRequest and for a related // reason: what it authorises is not reach into the organisation's // credentials but the ability to bring your own. The secrets it creates // carry an owner, and the broker refuses every attempt to list, grant or // delete one from any identity but that owner — so holding this permission // widens nobody's access to anything that already exists. // // It is emphatically not a weaker PermSecretGrant, and the distinction is // the one that makes the pair safe to hold together. secret.grant is // authority over the organisation's credentials — a maintainer deciding // that the fleet's deploy key may reach a repository. secret.own is // custody of your own, and the blast radius of misusing it is your own // account at GitHub. Merging them would mean either that a developer // cannot store a personal PAT without also being handed the fleet's, or // that the fleet's credentials become operator-reachable; both are worse // than a second permission. // // Not granted to viewer, on the same ground as secret.request: a viewer // cannot start a run, so a credential stored under their name is one // nothing they can do would ever spend. PermSecretOwn Permission = "secret.own" // PermViewPrefs is the right to change one's own dashboard presentation // — currently, which projects to hide from the project list. // // Granted from viewer up, making it the weakest permission in the // ladder, because it authorizes nothing about a project: it records a // preference under the caller's own viewer key, against a project they // can already see, and no other user can observe the result. Gating it // on project.write instead — the nearest existing permission a POST may // carry — would leave the roles that live in the dashboard all day as // the only ones unable to tidy it. // // It is not a weaker form of project.read. A hidden project is still // delivered to the caller and still reachable by index: hiding is // decluttering, not concealment, and must never be relied on to keep a // project out of someone's reach. PermViewPrefs Permission = "view.prefs" // PermPublic is not a permission. It is the explicit marker a route // declares when it must stay reachable before authorization can even be // evaluated — the login machinery, the SPA shell, static assets, and // /api/me. It exists so that "this route is unguarded" is a visible, // greppable decision in the route table rather than an omission. PermPublic Permission = "public" )
func FreshClaimPermissions ¶ added in v0.0.2
func FreshClaimPermissions() []Permission
FreshClaimPermissions returns every permission that requires fresh claims, in the order AllPermissions declares them.
For documentation and for the `cloop hub doctor` line that tells an operator what the bound actually covers on their build — a list they would otherwise have to reconstruct by hand from two tables.
func (Permission) Valid ¶
func (p Permission) Valid() bool
Valid reports whether p is a known permission. PermPublic is not a permission and reports false.
type Resolver ¶
type Resolver struct {
// contains filtered or unexported fields
}
Resolver evaluates Config against subjects and scopes. It is immutable after New and safe for concurrent use.
func New ¶
New validates cfg and returns a Resolver.
Validation is strict and fails closed: a typo in a role name or claim kind is an error at startup rather than a binding that silently never matches. Error messages name the offending entry by index so a misconfigured YAML file is diagnosable without reading this code.
func (*Resolver) Configured ¶
Configured reports whether an operator actually supplied an RBAC policy — at least one role mapping, or an explicit default_role.
This distinction exists so that turning on OIDC does not silently turn on deny-by-default. A deployment that enabled SSO before RBAC existed has no role_mappings; if the empty policy were enforced, upgrading would lock every user out of their own dashboard. Callers therefore treat an unconfigured resolver as "RBAC not in force" and keep the pre-RBAC behavior. Writing a single role_mapping (or setting default_role explicitly, including to "none") opts the deployment in, and from that point deny-by-default applies to everything the policy does not grant.
admin_emails alone does not count: it predates RBAC and only ever meant "sees every project".
func (*Resolver) DefaultRole ¶ added in v0.0.2
DefaultRole reports the role granted to an authenticated identity that matches no binding. It is what an unsatisfiable binding silently degrades to, so a diagnostic that names one should name this too.
func (*Resolver) DeniedBy ¶ added in v0.0.2
DeniedBy returns the runtime deny binding that withdraws subject's authority within scope, or nil.
It exists as a separate entry point because a deny must bite on hubs where Resolve is never consulted. RBAC is inactive unless an operator configured a policy (see Configured), and a deployment running on oidc.admin_emails alone has not — so every request there takes an allow-all path that never reaches Resolve. A demotion that only worked on hubs which had already adopted RBAC would be missing on exactly the older deployments most likely to still have a long-lived admin list.
Callers enforce this ahead of any authorization bypass. A nil subject or a hub with no runtime source yields nil at no cost.
func (*Resolver) HasRuntimeBindings ¶ added in v0.0.2
HasRuntimeBindings reports whether any runtime binding is in force.
Separate from RuntimeBindings because callers on the request path ask only this, and asking it by taking the length of a copy would allocate the whole slice on every request of every user for the lifetime of one deny binding.
func (*Resolver) Resolve ¶
Resolve computes the effective role and permission set for subject within scope. A nil subject denies everything.
Precedence between the runtime and configured layers ¶
Two rules, in this order, and nothing else:
- Deny wins. An applicable runtime binding with Deny set denies outright, whatever any other binding says and at whatever specificity. It is not ranked against allows and cannot be outvoted by one.
- The database overrides config. If any runtime binding applies, the configured bindings are not consulted at all — the winner is chosen among the runtime bindings alone, by the same tier-then-strength rule used within a layer.
"Applicable" still means scope-satisfying: a binding pinned to one project takes part only in decisions about that project, so `role revoke --project payments` withdraws authority there and nowhere else. Scoping is what keeps rule 1 from being blunt.
Both rules exist for the same case. oidc.admin_emails becomes a global admin binding (see New), so demoting a compromised administrator means beating a tier-0 admin. Ranking a deny against it would lose; consulting config after a runtime match would reinstate it. Anything short of both rules leaves the emergency lever attached to nothing.
func (*Resolver) RuntimeBindings ¶ added in v0.0.2
RuntimeBindings returns a copy of the runtime bindings currently in force, or nil when this hub has no runtime source.
Exported so an operator surface can show the same list the resolver is deciding from, rather than re-reading storage and possibly showing something else. A copy because the source owns a live cache it swaps on refresh, and a caller that sorted or filtered the returned slice in place would be mutating the array a concurrent Resolve is scanning. Resolution itself goes through runtimeBindings, which does not copy — this is a display path.
func (*Resolver) UnsatisfiableBindings ¶ added in v0.0.2
UnsatisfiableBindings reports the configured bindings that no identity from this issuer can ever match, judged from the claims one authenticated subject actually carried.
New already refuses a binding that is malformed — an unknown role, an unknown claim kind. What it cannot see is the other half of the contract: whether the identity provider releases the claim the binding reads. A hub whose `groups` scope was never granted on the client, or whose provider maps group membership under a different claim, accepts its role_mappings without complaint and then resolves every user to default_role. Nothing in the request path says why, because from resolution's point of view nothing went wrong: no binding matched, which is a perfectly ordinary outcome.
The judgement here is deliberately narrow. A binding is reported only when the claim *kind* it reads was absent from the token entirely — not when its value simply did not match, which is the normal case for every binding that belongs to somebody else. That narrowness is what makes the result worth logging: each entry is a binding that cannot match this user, and almost certainly cannot match any user of the same issuer.
A nil subject yields nothing: there is no evidence to reason from.
type Role ¶
type Role string
Role is a named bundle of permissions.
const ( // RoleNone holds no permissions. It is the default default: an // identity that matches no binding can do nothing until an operator // says otherwise. RoleNone Role = "none" // RoleViewer may read projects and see the executor fleet. RoleViewer Role = "viewer" // RoleOperator may additionally drive execution: start and stop runs // and mutate the task plan. This is the day-to-day engineer role. RoleOperator Role = "operator" // RoleMaintainer may additionally reshape a project and broker // credentials to executors: project write, config write, secret // grant/revoke. RoleMaintainer Role = "maintainer" // RoleAdmin holds every permission, including managing the executor // fleet and access control itself. RoleAdmin Role = "admin" )
func ParseRole ¶
ParseRole normalizes and validates a role name from an external source — a config file, a CLI flag, or a persisted API token. ok is false for an unknown name, which callers must treat as a refusal rather than silently substituting RoleNone: a token row carrying a role this binary no longer recognises has a meaning nobody can compute, and guessing it is how a downgrade turns into an escalation.
func (Role) Permissions ¶
func (r Role) Permissions() []Permission
Permissions returns the permission set granted by r, as a fresh sorted slice. An unknown role grants nothing.
type RuntimeSource ¶ added in v0.0.2
type RuntimeSource interface {
RuntimeBindings() []Binding
}
RuntimeSource yields the runtime role bindings currently in force.
Called on every authorization decision, so an implementation must be cheap and safe for concurrent use; pkg/rolestore satisfies it with a TTL-cached read of the control-plane database. Returning nil means "no runtime bindings", which is the ordinary case and must stay free.
An implementation that cannot reach its storage must return the last good answer rather than an empty slice. Failing open here would make a deny binding — the emergency demotion — evaporate exactly when the database is unhealthy, which is not a moment to hand authority back.
type Scope ¶
type Scope struct {
// Project is the project's registry name, if it has one.
Project string
// ProjectPath is the project's filesystem path. A binding's Project
// matches either Project or ProjectPath so operators may write
// whichever is stable for their deployment.
ProjectPath string
// Executor is the executor ID for executor-scoped actions.
Executor string
}
Scope identifies the resource a permission check is about. The zero value is the global scope: it satisfies only unscoped bindings, which is the correct conservative reading for fleet-wide actions.
type Source ¶
type Source string
Source records why a Decision came out the way it did. It is written to the audit log so a denial can be explained without re-running resolution.
const ( // SourceAuthzDisabled means RBAC is not in force (OIDC is off): the // single-tenant local behavior of granting everything. SourceAuthzDisabled Source = "authz_disabled" // SourceStaticToken means the caller presented the deployment's own // bearer token, which is an operator credential by definition. SourceStaticToken Source = "static_token" // SourceAPIToken means the caller presented a scoped personal access // token. Unlike SourceStaticToken this is *not* a bypass: the token // carries its own roles and the resulting decision is an ordinary // permission set, resolved by FromRoles rather than by binding lookup. SourceAPIToken Source = "api_token" // SourceBinding means a configured role mapping matched. SourceBinding Source = "binding" // SourceAdminEmail means the legacy oidc.admin_emails list matched. SourceAdminEmail Source = "admin_email" // SourceRuntimeBinding means a runtime binding — one written by an // operator rather than deployed in config — granted the role. SourceRuntimeBinding Source = "runtime_binding" // SourceRuntimeDeny means a runtime deny binding withdrew the caller's // authority. Distinct from SourceDefaultRole so the audit trail // distinguishes "nothing granted this person anything" from "somebody // took it away, deliberately, and there is a reason on file". SourceRuntimeDeny Source = "runtime_deny" // SourceDefaultRole means no binding matched and oidc.default_role // applied. SourceDefaultRole Source = "default_role" // SourceUnauthenticated means there was no identity to resolve. SourceUnauthenticated Source = "unauthenticated" )
type Subject ¶
type Subject struct {
// Sub is the issuer subject: stable, opaque, always present.
Sub string
// Email is the email claim, if released. May be empty.
Email string
// Groups holds the values of the group claim, already flattened from
// whatever shape the IdP used.
Groups []string
// Roles holds the values of the role claim, likewise flattened.
Roles []string
}
Subject is the set of claims cloop extracted from a validated ID token. A nil *Subject means "no authenticated identity", which resolves to a deny.
func (*Subject) Label ¶
Label returns the most human-meaningful identifier for audit records: email when available, else the subject.
func (*Subject) PresentClaims ¶ added in v0.0.2
PresentClaims names the claim kinds the identity provider actually released for s, in AllClaimKinds order.
It exists to be printed next to a binding that cannot match: "you bound on group, and this token carries only sub and email" is a diagnosis, where "no role mapping matched" is a symptom.