Documentation
¶
Overview ¶
Package authz evaluates the two authorization planes.
Control plane is role-scoped and governs managing an app. Data plane is binary and governs using one. They are never conflated: CheckData contains exactly one cross-plane implication, ownership (R-072), and this was reversed once during design.
Evaluation order is fixed and each step can only deny. See design 06 §2 and the package CLAUDE.md before changing anything here.
Index ¶
- Constants
- Variables
- func InstallScoped(v Verb) bool
- func IsVerb(v Verb) bool
- type Auditor
- type Authorizer
- func (a *Authorizer) Allows(ctx context.Context, p Principal, appID string, verb Verb) (bool, error)
- func (a *Authorizer) AllowsEach(ctx context.Context, p Principal, appIDs []string, verbs ...Verb) (map[string]map[Verb]bool, error)
- func (a *Authorizer) AllowsInstall(ctx context.Context, p Principal, verb Verb) (bool, error)
- func (a *Authorizer) AppVerbs(ctx context.Context, p Principal, appID string) ([]Verb, error)
- func (a *Authorizer) CheckControl(ctx context.Context, p Principal, appID string, verb Verb) error
- func (a *Authorizer) CheckData(ctx context.Context, p Principal, appID string) error
- func (a *Authorizer) CheckInstall(ctx context.Context, p Principal, verb Verb) error
- func (a *Authorizer) PreviewControl(ctx context.Context, p Principal, appID string, verb Verb) error
- type BatchStore
- type DataFacts
- type DataFactsReader
- type DenialAuditPolicy
- type Grant
- type Policy
- type Principal
- type PrincipalKind
- type Role
- type Snapshotter
- type Store
- type Verb
Constants ¶
const ( RoleViewer = "role_viewer" RoleOperator = "role_operator" RoleOwner = "role_owner" // RoleAdministrator is install-scoped and holds every install verb and no // app verb — but the install.apps.* verbs among them are every app verb // on every app (R-081), so an administrator can look after any app // without a grant on it. They are still not its owner: R-031's owner of // record is unchanged, and using the app still needs a data grant (R-087). RoleAdministrator = "role_administrator" // RoleCreator is install-scoped and holds one verb, app.create. A creator // manages the apps they make because making one writes them its owner // (R-073) — not because this role says anything about apps — so they // manage those and nothing else in the installation. RoleCreator = "role_creator" // RoleAppViewer and RoleAppManager are the Viewer and the Owner on every // app, install-scoped: the counterparts of each of their app verbs. They // replace the install.apps.view and install.apps.manage bundles, so a // grant of either reads the same as one of those did (issue #81). // App manager does not approve deploys, as Owner does not (R-155). RoleAppViewer = "role_app_viewer" RoleAppManager = "role_app_manager" // RoleAuditor sees every app, reads its logs and reads the audit log, and // changes nothing: the security group's role (issue #81). Not // install.view — the accounts, adapters and policy are not what it audits. RoleAuditor = "role_auditor" )
Built-in role IDs, seeded by migration and protected by trigger (R-081).
Variables ¶
var Verbs = []Verb{ InstallView, InstallUsersManage, InstallPolicyManage, InstallAdaptersManage, InstallAuditRead, InstallBackupManage, InstallAppsView, InstallAppsLogsRead, InstallAppsDeploy, InstallAppsRestart, InstallAppsSpecEdit, InstallAppsSecretsWrite, InstallAppsSecretsRead, InstallAppsExec, InstallAppsGrantsManage, InstallAppsRoutingOverride, InstallAppsResourcesOverride, InstallAppsEgressTighten, InstallAppsEgressLoosen, InstallAppsDelete, InstallTokensManage, InstallDeploysApprove, InstallUpgrade, InstallEventsManage, InstallAuditExport, AppCreate, AppView, AppLogsRead, AppDeploy, AppRestart, AppSpecEdit, AppSecretsWrite, AppSecretsRead, AppExec, AppGrantsManage, AppRoutingOverride, AppResourceOverride, AppEgressTighten, AppEgressLoosen, AppDeployApprove, AppDelete, }
Verbs is the catalog (design 06 §5, R-080).
There is no implication graph: holding app.delete does not imply app.view. Implication graphs are where authorization bugs live, because the graph is consulted in one place and forgotten in another. The console suggests sensible combinations instead, which is a UI concern and cannot go wrong silently.
Functions ¶
func InstallScoped ¶
InstallScoped reports whether a verb is held install-wide rather than on one app.
Checked at the boundary between CheckControl and CheckInstall so that a verb cannot be authorized in the wrong scope. Passing an install verb to CheckControl, or an app verb to CheckInstall, is a programming error and is refused rather than evaluated — an install verb evaluated against an app would look for a grant that cannot exist and deny, which is safe; an app verb evaluated install-wide would look for a grant that *can* exist and allow, which is not.
Types ¶
type Auditor ¶
type Auditor interface {
Denied(ctx context.Context, p Principal, appID string, verb Verb, reason errs.Code)
// ThroughInstall records an app verb allowed by an install-wide grant
// rather than one on the app (issue #81), so the audit log shows that
// the access came from everywhere and not from here: which install verb,
// through which grant. Not called for app.view and app.logs.read, which
// the console asks on every screen and every few seconds (see
// auditsReach).
ThroughInstall(ctx context.Context, p Principal, appID string, verb, through Verb, grant Grant)
}
Auditor records authorization outcomes.
type Authorizer ¶
type Authorizer struct {
// contains filtered or unexported fields
}
Authorizer evaluates both planes.
func (*Authorizer) Allows ¶
func (a *Authorizer) Allows(ctx context.Context, p Principal, appID string, verb Verb) (bool, error)
Allows reports whether CheckControl would allow the verb, without auditing a denial — for deciding what to show, as AppVerbs does, one verb at a time.
func (*Authorizer) AllowsEach ¶
func (a *Authorizer) AllowsEach(ctx context.Context, p Principal, appIDs []string, verbs ...Verb) (map[string]map[Verb]bool, error)
AllowsEach is Allows for several apps and verbs at once: for each app, the verbs CheckControl would allow on it, without auditing a denial. For a list that shows what the caller may do on each of its rows (issue #72).
Each answer is the one Allows gives, by the same rules in the same order: the principal, the policy document, the install grants and their roles are read once for the call, as AppVerbs reads them, and the control grants for every app in one query when the store is a BatchStore. None of it outlives the call (R-274).
func (*Authorizer) AllowsInstall ¶
AllowsInstall reports whether CheckInstall would allow the verb, without auditing a denial. For a decision with two ways to be allowed — deploy approval is install.deploys.approve or app.deploy.approve (R-155) — where failing the first is not a denial at all if the second succeeds, and an audit log full of denials nobody suffered would bury the ones that matter.
func (*Authorizer) AppVerbs ¶
AppVerbs returns the app verbs the principal may use on an app, by asking the same question CheckControl asks for each — so what the console shows as editable is what the API will allow, and cannot drift from it. Denials are not audited: this is somebody looking at a screen, not trying anything.
The question is asked once per app verb, with the same rules as control, but the principal's status, the policy document, the grants and their roles are read once for the whole call rather than once per verb (lookups). None of it outlives the call (R-274).
func (*Authorizer) CheckControl ¶
CheckControl authorizes a control-plane action: managing an app.
The order is fixed and each step can only deny; none can restore access denied by an earlier step (design 06 §2).
func (*Authorizer) CheckData ¶
CheckData authorizes data-plane access: using an app through the proxy.
This function contains exactly ONE cross-plane implication — ownership (R-072). No other control-plane role appears in it, and being a Pando admin does not appear in it at all (R-087: an admin has root and can reach a container outside Pando, but the supported path requires a grant).
This was reversed once during design. If a change adds a control-plane check here, reject it — that is R-029, and the test asserting an operator on someone else's app is denied *use* exists to catch exactly that change.
func (*Authorizer) CheckInstall ¶
CheckInstall authorizes an installation-wide action (O-17, R-265).
A separate function from CheckControl, taking different arguments, for the same reason CheckData is separate: the scopes are different questions and a single function with an optional appID is one missed argument away from authorizing an app verb install-wide. That mistake has already been made once in this package's history, in the other direction (design 06 §2).
Host policy still runs first (R-272) and still denies the holder: an install that has disabled a verb has disabled it for administrators too.
func (*Authorizer) PreviewControl ¶
func (a *Authorizer) PreviewControl(ctx context.Context, p Principal, appID string, verb Verb) error
PreviewControl is CheckControl's answer without auditing a denial: the same refusal, with the same reason, for a dry run that asks what would happen and changes nothing. A denial nobody suffered is not an event (design 06 §6).
type BatchStore ¶
type BatchStore interface {
// ControlGrantsForApps returns ControlGrantsFor's answer for each app,
// with an entry, possibly empty, for every app asked about.
ControlGrantsForApps(ctx context.Context, appIDs []string, p Principal) (map[string][]Grant, error)
}
BatchStore is a Store that can read the principal's control grants on several apps in one call. AllowsEach uses it when the store offers it; a store without it is asked app by app, with the same answers.
type DataFacts ¶
type DataFacts struct {
Owner bool // the caller is the app's owner of record (R-072)
Grant bool // a data-plane grant reaches the caller: direct, group or account token
Anonymous bool // the app is shared with everyone (R-075)
Passcode bool // and that sharing asks for a passcode (R-075a)
Unlocked bool // the unlock this request brought is live
}
DataFacts is everything CheckData decides on, about one app and one caller (issue #93). Facts only: the decision, and its order, stay in CheckData.
type DataFactsReader ¶
type DataFactsReader interface {
DataFacts(ctx context.Context, appID string, p Principal, passcodeToken string) (DataFacts, error)
}
DataFactsReader is a Store that reads DataFacts in one go — the state store's one query in place of four. A Store without it is asked fact by fact, stopping as soon as the answer is known.
type DenialAuditPolicy ¶
DenialAuditPolicy is a Policy that says whether anonymous denials on the data plane are audited. A policy that does not implement it audits them.
type Grant ¶
type Grant struct {
ID string
AppID string
Plane string
PrincipalKind string
PrincipalID string
RoleID string
// Role is the grant's role, when the store read it with the grant. Nil
// means it did not, and the authorizer asks Store.Role for it.
Role *Role
}
Grant binds a principal to an app on one plane.
type Policy ¶
type Policy interface {
// Allows reports whether host policy permits the verb at all. Policy is a
// floor, not an override (R-272): a policy disabling exec install-wide
// denies the owner too.
//
// The principal is passed because O-12's resolution needs it. "Agents may
// not exec here" is expressed as a policy scoped to token principals rather
// than as an MCP-layer exclusion list, because an agent holding a token can
// call the REST API directly — an MCP-specific block is a speed bump, not a
// boundary. Enforcement has to live where every surface passes through it,
// and this is that place.
Allows(ctx context.Context, p Principal, verb Verb, appID string) error
}
Policy is host policy, evaluated before grants.
type Principal ¶
type Principal struct {
Kind PrincipalKind
ID string
// UserID is the user authorization runs against. For a user it equals ID.
// For a delegated token it is the owner (R-058, R-059) — authorization then
// proceeds exactly as if the owner made the request, and no grant is ever
// written for the token itself.
//
// For an account token it is empty: an account token is its own principal
// and is looked up in grants under its own ID (R-060).
UserID string
TokenID string
// Groups are resolved live per request (R-079), never denormalized.
Groups []string
// Email and DisplayName travel into the assertion's claims. They are
// display detail, never used for authorization — an email is not an
// identity here, users.id is (R-054).
Email string
DisplayName string
AdapterID string
// Status of the underlying user, checked at step 2 of the evaluation order.
Status string
// Passcodes are the unlock tokens the request carried, by app ID (R-075a):
// proof that whoever is visiting entered a passcode-protected app's
// passcode. Unverified as carried — CheckData asks the store whether each
// is live, on every request, as it asks about everything else.
Passcodes map[string]string
}
Principal is the resolved identity of a request (design 06 §1).
type PrincipalKind ¶
type PrincipalKind string
PrincipalKind identifies what is making a request.
const ( KindUser PrincipalKind = "user" KindToken PrincipalKind = "token" KindAnonymous PrincipalKind = "anonymous" KindSystem PrincipalKind = "system" )
type Role ¶
type Role struct {
ID string `json:"id"`
Name string `json:"name"`
Builtin bool `json:"builtin"`
Verbs []Verb `json:"verbs"`
}
Role is a named set of verbs.
Tagged because this type is serialized directly by the API, and every other type on the wire is lower_snake_case. An untagged struct would put Go field names into the API surface — where they would then be a compatibility promise, and renaming a field would be a breaking change to a client.
type Snapshotter ¶
Snapshotter is a Policy that can answer several questions from one read of its document. AppVerbs asks policy about every app verb for one screen, and reading the document once per verb was most of what that cost.
The snapshot lives for one call and is never kept: policy applies to a running install the moment it changes (R-274), so a snapshot held across requests would be the cache R-274 rules out.
type Store ¶
type Store interface {
// UserStatus returns the status of a user: active, suspended, or deleted.
UserStatus(ctx context.Context, userID string) (string, error)
// ControlGrantsFor returns control-plane grants matching the principal —
// direct, by group, or by account token.
ControlGrantsFor(ctx context.Context, appID string, p Principal) ([]Grant, error)
// InstallGrantsFor returns the principal's installation-wide grants: the
// ones with no app (O-17). A separate query from ControlGrantsFor rather
// than a nullable argument, so an app-scoped lookup can never return an
// install grant by passing the wrong value.
InstallGrantsFor(ctx context.Context, p Principal) ([]Grant, error)
// IsOwner reports whether userID owns the app (R-031).
IsOwner(ctx context.Context, appID, userID string) (bool, error)
// HasDataGrant reports whether the principal holds a data-plane grant,
// directly or through a group.
HasDataGrant(ctx context.Context, appID string, p Principal) (bool, error)
// AnonymousAccess reports whether the app is shared with everyone (R-075),
// and whether that sharing asks for a passcode (R-075a).
AnonymousAccess(ctx context.Context, appID string) (granted, passcode bool, err error)
// PasscodeUnlocked reports whether token is a live unlock of this app's
// passcode: entered, not expired, and made under the grant that stands now.
PasscodeUnlocked(ctx context.Context, appID, token string) (bool, error)
// Role returns a role by ID.
Role(ctx context.Context, roleID string) (Role, error)
}
Store is the authorizer's view of persisted state.
Narrow on purpose: the authorizer must not be able to reach anything else, and a small interface makes the evaluation order testable without a database.
type Verb ¶
type Verb string
Verb is a control-plane permission.
Data-plane use is not a verb. Reaching an app through the proxy is binary (R-070) and is checked by CheckData, which is a different function taking different arguments for exactly that reason.
const ( // Install-scoped verbs. Held through a grant with no app (O-17), which is // what makes "an administrator" a principal with a grant rather than a // separate mechanism beside grants and roles. // // Namespaced `install.*` so the scope is visible at every call site: a verb // that reads `install.` cannot be mistaken for one that needs an app, which // is the mistake that would reintroduce the hole these close. InstallView Verb = "install.view" InstallUsersManage Verb = "install.users.manage" InstallPolicyManage Verb = "install.policy.manage" InstallAdaptersManage Verb = "install.adapters.manage" InstallAuditRead Verb = "install.audit.read" // InstallBackupManage covers taking, verifying and restoring backups. // Its own verb rather than part of install.policy.manage: a restore // replaces the entire install, and folding that into the verb that edits a // source allowlist would hand it to everyone who could edit one. InstallBackupManage Verb = "install.backup.manage" // The install.apps.* verbs are each app verb's install-wide counterpart: // install.apps.deploy is app.deploy on every app, including apps made // later (issue #81). They are how an install grant bears on an app — the // one sanctioned way, read in exactly one place, CheckControl, through the // table in everyApp. One verb per app verb rather than bundles, so a // custom role can carry "read every app's logs" without "deploy every // app", and so a group gets a permission once instead of a grant on each // app. The old bundles, install.apps.view as Viewer and // install.apps.manage as Owner on every app, are built-in roles now: // App viewer and App manager (R-081). // // Control plane only. None appears in CheckData: using an app still needs // a data grant or ownership (R-072, R-087). InstallAppsView Verb = "install.apps.view" InstallAppsLogsRead Verb = "install.apps.logs.read" InstallAppsDeploy Verb = "install.apps.deploy" InstallAppsRestart Verb = "install.apps.restart" InstallAppsSpecEdit Verb = "install.apps.spec.edit" InstallAppsSecretsWrite Verb = "install.apps.secrets.write" InstallAppsSecretsRead Verb = "install.apps.secrets.read" InstallAppsExec Verb = "install.apps.exec" InstallAppsGrantsManage Verb = "install.apps.grants.manage" InstallAppsRoutingOverride Verb = "install.apps.routing.override" InstallAppsResourcesOverride Verb = "install.apps.resources.override" InstallAppsEgressTighten Verb = "install.apps.egress.tighten" InstallAppsEgressLoosen Verb = "install.apps.egress.loosen" InstallAppsDelete Verb = "install.apps.delete" // InstallTokensManage covers service tokens (R-060): listing, creating and // revoking them. Its own verb rather than install.users.manage's, because a // service token is a principal an automation acts as — issuing one is a // different trust from managing people, and an installation may want // somebody who runs its CI to hold one without the other. InstallTokensManage Verb = "install.tokens.manage" // InstallDeploysApprove approves or rejects any deploy that needs // approval, on any app, including the holder's own (R-155). It is // app.deploy.approve's install-wide counterpart, older than the // install.apps.* verbs and named for what it does rather than renamed to // match them. Not in App manager: approval is a trust an installation // hands out on purpose, and managing apps does not imply it. InstallDeploysApprove Verb = "install.deploys.approve" // InstallUpgrade upgrades Pando itself in place (R-356): it replaces the // server everything else runs behind, so it is its own trust rather than // part of managing policy or adapters. InstallUpgrade Verb = "install.upgrade" // InstallEventsManage subscribes to events install-wide and manages // anybody's subscriptions (R-368). An install-wide subscription hears about // every app and about sign-ins, which is why seeing one app is not enough. InstallEventsManage Verb = "install.events.manage" // InstallAuditExport sends the audit log off the installation: creating, // changing or removing an audit sink (R-385). Not part of // install.audit.read — an auditor reads the log where it is, and pointing // it somewhere new is a different thing to trust someone with. InstallAuditExport Verb = "install.audit.export" // AppCreate is install-scoped despite its name: there is no app yet when it // is checked. Sequence A step 1 has always called it install-level. AppCreate Verb = "app.create" AppView Verb = "app.view" AppLogsRead Verb = "app.logs.read" AppDeploy Verb = "app.deploy" AppRestart Verb = "app.restart" AppSpecEdit Verb = "app.spec.edit" AppSecretsWrite Verb = "app.secrets.write" AppSecretsRead Verb = "app.secrets.read" AppExec Verb = "app.exec" AppGrantsManage Verb = "app.grants.manage" AppRoutingOverride Verb = "app.routing.override" AppResourceOverride Verb = "app.resources.override" // AppEgressTighten changes an app's egress within the installation's // rules (R-182, R-184): its own list, additions to a denylist, removals // from an allowlist, private addresses blocked. Never beyond policy // (R-272), so Operator holds it as well as Owner. AppEgressTighten Verb = "app.egress.tighten" // AppEgressLoosen loosens the installation's egress rules for an app, // where host policy permits loosening by verb (R-183, R-184). It was // app.egress.override until issue #79; the migration renamed it in every // role that held it. Holding it covers every egress change, tightening // included. AppEgressLoosen Verb = "app.egress.loosen" // AppDeployApprove approves or rejects this app's deploys that need // approval (R-155). In no built-in role: an installation grants it // deliberately, through a custom role. AppDeployApprove Verb = "app.deploy.approve" AppDelete Verb = "app.delete" )
func EveryApp ¶
EveryApp returns the app verb an install verb stands for on every app, and whether it stands for one. For listing and explaining access; deciding it is CheckControl's.
func InstallCounterpart ¶
InstallCounterpart returns the install verb that stands for an app verb on every app, and whether there is one.