Documentation
¶
Overview ¶
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.
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 ¶
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, }
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 ¶
This section is empty.
Types ¶
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
}
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.
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
}
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" // 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 (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".
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 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" // 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.