Documentation
¶
Overview ¶
Package policy is the schema both services load: seven tables that answer four questions and no others — who is in which internal group, what a group adds to a token, how long a token lives, and which client may be issued one — plus one OPTIONAL eighth, Vocabulary, that answers none of the four itself and instead constrains what a name used to answer them is allowed to mean.
Everything that shapes a token is derivable from these tables by reading them. That is the whole design goal, and it is why there is no expression language, no per-client rewriting and no precedence order: each of those makes a token's shape something you have to execute rather than read. The reference is docs/reference/policy.md.
Index ¶
- Constants
- Variables
- func AgentAbsolute(class time.Duration, touched []string, lookup func(id string) (Resource, bool)) time.Duration
- func EffectiveAbsolute(global time.Duration, touched []string, ...) time.Duration
- func IsAccessDocument(data []byte) bool
- func Plain(v any) any
- func RewriteGroupsDelimiter(name, delimiter string) string
- func ScopedGroup(workspace, role string) string
- func SplitGroup(name string) (scope, thing, role string, ok bool)
- func SplitScopedGroup(name string) (workspace, role string, mine bool)
- func ValidAWSAccount(s string) bool
- func ValidSlackChannelID(id string) bool
- func ValidWorkspaceID(s string) bool
- type AWSMatcher
- type AWSRole
- type Access
- type AccessAWSRole
- type AccessClient
- type AccessClientDocuments
- type AccessDocument
- type AccessGitHubJob
- type AccessGitHubTeam
- type AccessGroup
- type AccessOrg
- type AccessOverlay
- type AccessPerson
- type AccessResource
- type AccessRole
- type AccessScope
- type AccessSlackChannel
- type AccessThing
- type AccessVocabulary
- type AccessWorkspace
- type Client
- type ClientDocuments
- type ClientSecret
- type ClientView
- type Duration
- type Fragment
- type GitHubClaims
- type GitHubMatcher
- type GitHubOrg
- type GitHubTeam
- type Group
- type GroupView
- type GroupsOverride
- type Held
- type Input
- type Matcher
- type MatcherView
- type Member
- type OrgView
- type OverlayGroup
- type Policy
- func (p Policy) Digest() (string, error)
- func (p Policy) Evaluate(in Input) Result
- func (p *Policy) Merge(other Policy, from string) error
- func (p Policy) PeopleByAddress() map[string]string
- func (p Policy) ScopeGroups(audience string, held []string) (kept, dropped []string)
- func (p Policy) Unconsumed(catalogueGroups ...string) []string
- func (p Policy) UnconventionalGroups() []string
- func (p Policy) Validate() error
- type Resource
- type ResourceView
- type Result
- type RoleSpec
- type ScopeSpec
- type ServiceAccountMatcher
- type ServiceAccountRef
- type Set
- func (s *Set) AgentAbsolute(class time.Duration, touched []string) time.Duration
- func (s *Set) BrowserFacingAgents() []string
- func (s *Set) Client(id string) (Client, bool)
- func (s *Set) ClientDocuments() ClientDocuments
- func (s *Set) Clients() []ClientView
- func (s *Set) Declared() Policy
- func (s *Set) Digest() string
- func (s *Set) EffectiveAbsolute(global time.Duration, touched []string) time.Duration
- func (s *Set) Evaluate(in Input) Result
- func (s *Set) GitHubOrgs() []OrgView
- func (s *Set) GitHubTeams() []TeamView
- func (s *Set) Groups() []GroupView
- func (s *Set) HasGroup(name string) bool
- func (s *Set) Resource(id string) (Resource, bool)
- func (s *Set) Resources() []ResourceView
- func (s *Set) ScopeGroups(audience string, held []string) (kept, dropped []string)
- func (s *Set) SlackWorkspaceDeclared(key string) bool
- func (s *Set) SlackWorkspaceKeys() []string
- type Slack
- type SlackChannel
- type SlackWorkspace
- type TeamView
- type ThingSpec
- type Vocabulary
Constants ¶
const ( // ThingSelf is what this hub calls itself in the `thing` position. // It holds no role vocabulary of its own — an identity is an operator // because the policy puts it in the operators group, exactly as any // other relying party's roles work — so its own two roles are named // by the same rule as everyone else's. ThingSelf = "access-roster" // ScopeAll is the scope of a role over the whole installation rather // than one directory in it. A real answer, not a placeholder: // `all:access-roster:operator` operates every connected directory, // which is exactly what the name says. ScopeAll = "all" // RoleOperator and RoleViewer are the two roles this hub reads. RoleOperator = "operator" RoleViewer = "viewer" // RoleReporter was held by a component in another process that // reported what it did through this service. // // Deprecated: nothing reads it. A component records into the audit // installation directly, as its own workload. RoleReporter = "reporter" // Separator divides a grant's three segments. Separator = ":" // FamilyRung and FamilyEmp are the two families of two-segment name // that are deliberately not grants -- `rung:<name>` and `emp:<slug>`, // above. Neither has a <thing> a `requires` pair or a [GroupsOverride] // thing entry could match, so a [GroupsOverride] entry may name the // FAMILY instead, to keep every held name of it at once // ([Policy.ScopeGroups] reads this the same way it reads a thing // name); see policy.md#groups-in-a-token-scoping. FamilyRung = "rung" FamilyEmp = "emp" )
Every grant in this policy is named `<scope>:<thing>:<role>` — role, on thing, in scope. `dev:k8s:admin` is admin of dev's Kubernetes; `prod:shop:deployer` deploys the shop project on prod; `all:access-roster:operator` operates this hub across every directory it serves. The reasoning is in docs/explanation/trust.md under "Naming"; what matters here is that the name is the whole of the fact, carried verbatim into a token's `groups` claim and out of it into a relying party's own bindings, re-mapped nowhere in between.
Two-segment names are deliberately NOT grants: `rung:<name>` carries a session lifetime and `emp:<slug>` is a person, neither being a role on a thing. A reader who sees two segments knows.
const ( // KindPublic has no secret: kubelogin per cluster, a CLI, a // development client. KindPublic = "public" // KindConfidential holds a secret: a console's proxy, a CD system. KindConfidential = "confidential" // KindExchange is a token-exchange target: a cloud role. KindExchange = "exchange" )
The client kinds.
const ( // SessionInteractive is a person at a browser: the installation's // `lifetimes` hold. It is what an absent `session` means. SessionInteractive = "interactive" // SessionAgent is software that holds a refresh token in its own // credential store and works in the background, such as an MCP host: // the service configuration's `lifetimes.agent` hold. SessionAgent = "agent" )
The session classes a client's refresh chains may have: `session` on a declared client and on `client_documents`. See docs/decisions/0040-agent-class-sessions.md.
const ( SigningAlgRS256 = "RS256" SigningAlgES256 = "ES256" SigningAlgES384 = "ES384" )
The signing algorithms a client or a resource may pin with `signing_alg`. Exactly three, not the four [algorithms this issuer can ever produce]: ES512 is a curve this issuer accepts for a KEY, but no relying party has asked to be pinned to it, and a fourth choice here is a fourth thing an operator has to reason about for no relying party's benefit. See docs/decisions/0009-a-default-signing-algorithm-and-per-audience-exceptions.md for why a pin exists here at all.
const ( MaxDisplayName = 80 MaxDescription = 200 )
The longest a client's display text may be, in characters. A name is a heading and a description is one line; anything longer is a paragraph somebody pasted into the wrong field, and the page it lands on is the one a person reads while deciding whether to trust it.
const ( // SlackModeExtend only adds. It is the default. SlackModeExtend = "extend" // SlackModeStrict adds and removes. SlackModeStrict = "strict" )
The channel modes.
const LifetimeDefault = "default"
LifetimeDefault is the key under which the fallback lifetime lives.
const MaxAbsoluteCap = 7 * 24 * time.Hour
MaxAbsoluteCap is the longest absolute session a resource may carry, whatever it says: seven days from `auth_time`. docs/decisions/0033-a-longer-absolute-limit-for-read-only-resources.md is why this number and why only for a read-only resource.
Variables ¶
var ( GroupOperators = ScopedGroup(ScopeAll, RoleOperator) GroupViewers = ScopedGroup(ScopeAll, RoleViewer) // GroupReporters was the workloads that could report audit events // through this service. // // Deprecated: nothing reads it; see [RoleReporter]. GroupReporters = ScopedGroup(ScopeAll, RoleReporter) )
The names the hub reads out of the policy for itself, installation wide.
var ErrUnknownGroup = errors.New("policy: not a declared group")
ErrUnknownGroup is returned for a group name the policy does not have.
var SigningAlgs = []string{SigningAlgRS256, SigningAlgES256, SigningAlgES384}
SigningAlgs is every value `signing_alg` accepts, in the order the docs list them. It is exported so the issuer -- which is where a pin is actually checked against the keys this installation was given -- can report the same list back in a refusal, rather than a second one that might drift from this one.
Functions ¶
func AgentAbsolute ¶ added in v1.69.0
func AgentAbsolute(class time.Duration, touched []string, lookup func(id string) (Resource, bool)) time.Duration
AgentAbsolute is the absolute limit of an AGENT-class refresh chain that has been used for the given resources: the class's own limit (`lifetimes.agent.absolute`), shortened by any `absolute_cap` among them.
For an agent chain a resource's cap is only ever a ceiling. Unlike EffectiveAbsolute it never lengthens anything, read-only or not, and the client's own audience (an empty id) or a resource that is not declared contributes nothing: the class's limit already bounds them. See docs/decisions/0040-agent-class-sessions.md.
func EffectiveAbsolute ¶
func EffectiveAbsolute(global time.Duration, touched []string, lookup func(id string) (Resource, bool)) time.Duration
EffectiveAbsolute is the absolute session limit of a refresh chain that has been used for the given resources: the SHORTEST limit among them.
Each resource contributes its own limit (Resource.AbsoluteFor), and the client's own audience -- an empty id -- and any resource that is not declared contribute the installation's. So a chain that has ever touched something not extended falls back to the installation's limit, and extension is only ever the product of EVERY resource agreeing to it. global at or below zero means the installation has no limit and nothing here adds one.
func IsAccessDocument ¶
IsAccessDocument reports whether a YAML file is an AccessDocument rather than a Policy layer: it carries an `access` key, which a policy layer never has.
func Plain ¶
Plain returns a value with every named map type replaced by map[string]any, so that an encoder which type-switches on the unnamed type sees what it expects.
func RewriteGroupsDelimiter ¶
RewriteGroupsDelimiter replaces every Separator in name with delimiter. An empty delimiter returns name unchanged -- the default, unset shape -- so a caller need not branch on whether an audience configured one.
This is the one place the substitution [validGroupsDelimiter] and [Policy.checkGroupsDelimiterCollision] validate at load is actually applied to a name; the issuer calls it once per group in a minted token's `groups` claim, AFTER Policy.ScopeGroups has already decided which groups survive -- see docs/reference/policy.md#groups-delimiter-per-audience-opkssh-interop.
func ScopedGroup ¶
ScopedGroup names the group that grants one of this hub's roles over one workspace — `C0example:access-roster:operator` administers that tenant and no other — or over the whole installation, with ScopeAll.
The scope is a naming convention over the ordinary groups table rather than a column in it, because the table is already the place an installation says who is in what, and the hub already reads two names out of it by convention. A scope is the first segment of those names, so nothing in the policy's schema, its merge or its validation has to know.
It is a workspace **id**, never a domain: a tenant is identified by what its backend calls it, and a domain can move between tenants.
func SplitGroup ¶
SplitGroup reads any grant back into its three segments — role, on thing, in scope — and reports false for a name that is not a grant.
It is the one place the estate takes the shape apart. This hub reads its own two roles through it, and a relying party that keys its authorization on the scope or the role (an audit log granting a tenant's history to `<tenant>:audit:viewer`, say) imports it rather than writing a second reader that agrees with this one until the day it does not.
The scope is returned as written: ScopeAll comes back as "all", and what that means is the reader's to decide, because it differs — this hub reads it as the absence of a scope, an audit log as every tenant. A two-segment name, an empty segment or a fourth one is not a grant.
func SplitScopedGroup ¶
SplitScopedGroup reads one of this hub's group names back into the workspace it is scoped to and the role it grants. The bool is false for any other name — another relying party's grant, a rung, an employee — which is what keeps this hub from reading a role out of a name that was never about it.
ScopeAll in the scope position is reported as the empty workspace: installation-wide, which is the absence of a scope rather than a scope named "all".
func ValidAWSAccount ¶
ValidAWSAccount reports whether s is an AWS account id: exactly twelve ASCII digits.
func ValidSlackChannelID ¶
ValidSlackChannelID reports whether a string has the shape of a Slack channel id. The one definition: a policy binding's `adopt` and a console or Slack Connect record's channel id are both checked with it.
func ValidWorkspaceID ¶
ValidWorkspaceID reports whether s can stand in the scope position of ScopedGroup: a single segment — no separator, no whitespace — that is not the installation-wide ScopeAll. It is how an organisation's `owner` is checked, so the name an organisation is owned by is one a scoped role could actually be held over.
Types ¶
type AWSMatcher ¶
type AWSMatcher struct {
// Account is the 12-digit AWS account id. Exact and required: a role
// name means nothing without its account, and a pattern here would let
// a role of that name in any account at all be admitted.
Account string `yaml:"account"`
// Role is the role's name, without its path. A glob in the sense of
// path.Match.
Role string `yaml:"role,omitempty"`
// Path is the role's IAM path, `/` for a role with none and
// `/service/team/` otherwise: leading and trailing slash, as AWS spells
// it. A glob where `*` does not cross a `/`, so `/service/*/` is one
// level; leave it out to match any path.
Path string `yaml:"path,omitempty"`
// Function pins the Lambda function the token was requested from, by
// ARN (a glob). A role shared by several functions is admitted as a
// role; this is for the rule that must admit one of them only. A
// proof that carries no function never matches a rule that sets it.
Function string `yaml:"function,omitempty"`
// OrgID pins the AWS Organizations id of the account (a glob).
OrgID string `yaml:"org_id,omitempty"`
}
AWSMatcher matches an AWS IAM role. The identity is the ROLE, never the function, instance or task that happens to be running as it: which workload uses a role is the account owner's business, and a rule here that named one would be a rule the owner could silently outgrow.
func (AWSMatcher) Rule ¶
func (a AWSMatcher) Rule() string
Rule renders the matcher's pattern: the account, then whatever else it pins, in the order an ARN reads.
type AWSRole ¶
type AWSRole struct {
// Account is the 12-digit account id, taken from the configured row
// that verified the token and cross-checked with the token's own
// claim and the ARN.
Account string
// Name is the role's name without its path.
Name string
// Path is the role's IAM path: `/`, or `/service/team/`.
Path string
// OrgID is the account's AWS Organizations id, when the token states
// one. Audit and matching only.
OrgID string
// Function is the Lambda function ARN the token was requested from,
// when it says so. It qualifies a matcher and is recorded; it is never
// the subject.
Function string
}
AWSRole is a verified AWS IAM role: the principal an outbound identity federation token names. It is the role and never the session, so the subject of everything it does stays one subject.
type Access ¶
type Access struct {
Lifetimes map[string]Duration `yaml:"lifetimes,omitempty"`
Groups []AccessGroup `yaml:"groups,omitempty"`
People []AccessPerson `yaml:"people,omitempty"`
Slack []AccessWorkspace `yaml:"slack,omitempty"`
GitHub []AccessOrg `yaml:"github,omitempty"`
Vocabulary *AccessVocabulary `yaml:"vocabulary,omitempty"`
Clients []AccessClient `yaml:"clients,omitempty"`
ClientDocuments *AccessClientDocuments `yaml:"clientDocuments,omitempty"`
Resources []AccessResource `yaml:"resources,omitempty"`
}
Access is the derived part of an AccessDocument.
type AccessAWSRole ¶
type AccessAWSRole struct {
Account string `yaml:"account,omitempty"`
Role string `yaml:"role,omitempty"`
Path string `yaml:"path,omitempty"`
}
AccessAWSRole is the part of an AWSMatcher a derived group carries.
type AccessClient ¶
type AccessClient struct {
Name string `yaml:"name"`
Kind string `yaml:"kind"`
DisplayName string `yaml:"displayName,omitempty"`
Description string `yaml:"description,omitempty"`
Secret string `yaml:"secret,omitempty"`
SecretKey string `yaml:"secretKey,omitempty"`
Redirects []string `yaml:"redirects,omitempty"`
SignedOut []string `yaml:"signedOut,omitempty"`
BackchannelLogout string `yaml:"backchannelLogout,omitempty"`
TTLCap Duration `yaml:"ttlCap,omitempty"`
SigningAlg string `yaml:"signingAlg,omitempty"`
GroupsDelimiter string `yaml:"groupsDelimiter,omitempty"`
SignInExchange bool `yaml:"signInExchange,omitempty"`
Requires []string `yaml:"requires,omitempty"`
Groups GroupsOverride `yaml:"groups,omitempty"`
Session string `yaml:"session,omitempty"`
Hostname string `yaml:"hostname,omitempty"`
Prefix string `yaml:"prefix,omitempty"`
Mount string `yaml:"mount,omitempty"`
Cluster string `yaml:"cluster,omitempty"`
Proxy any `yaml:"proxy,omitempty"`
Deliver any `yaml:"deliver,omitempty"`
}
AccessClient is one declared client. `secretKey` and the last six fields describe the relying party's own deployment (the key its secret is projected under, where it is served, how it is reached) and are read by whatever deploys it; they take no part in the policy and are accepted so that one row serves both.
type AccessClientDocuments ¶
type AccessClientDocuments struct {
Origins []string `yaml:"origins,omitempty"`
Requires []string `yaml:"requires,omitempty"`
TTLCap Duration `yaml:"ttlCap,omitempty"`
Groups GroupsOverride `yaml:"groups,omitempty"`
Session string `yaml:"session,omitempty"`
}
AccessClientDocuments is ClientDocuments in the document's spelling.
type AccessDocument ¶
type AccessDocument struct {
// Version is the policy schema's, as in [Policy.Version].
Version int `yaml:"version"`
// Access is the derived part, the same for every installation of an
// estate.
Access Access `yaml:"access"`
// Overlay is the part that is this installation's own: how it is
// reached before a directory can vouch for anybody, and the clients
// that are not relying parties of the access matrix.
Overlay AccessOverlay `yaml:"overlay,omitempty"`
}
AccessDocument is one file: the access sections and the estate overlay.
version: 1
access:
lifetimes: {default: 4h}
groups:
- name: env:ssh:admin
emails: [ada@example.com]
overlay:
groups:
all:access-roster:operator:
matchers: [...]
type AccessGitHubJob ¶
type AccessGitHubJob struct {
Repository string `yaml:"repository,omitempty"`
Owner string `yaml:"owner,omitempty"`
Visibility string `yaml:"visibility,omitempty"`
Ref string `yaml:"ref,omitempty"`
RefType string `yaml:"ref_type,omitempty"`
EventName string `yaml:"event_name,omitempty"`
WorkflowRef string `yaml:"workflow_ref,omitempty"`
JobWorkflowRef string `yaml:"job_workflow_ref,omitempty"`
}
AccessGitHubJob is the part of a GitHubMatcher a derived group carries.
type AccessGitHubTeam ¶
type AccessGitHubTeam struct {
Slug string `yaml:"slug"`
Members []string `yaml:"members,omitempty"`
Maintainers []string `yaml:"maintainers,omitempty"`
}
AccessGitHubTeam is one team's bindings.
type AccessGroup ¶
type AccessGroup struct {
Name string `yaml:"name"`
// Members are directory groups.
Members []string `yaml:"members,omitempty"`
// Emails admit one signed-in address each.
Emails []string `yaml:"emails,omitempty"`
// GitHub admits a CI job by what its verified token says. Only the
// fields below are carried; a derivation that needs another asks for
// it here, in the open.
GitHub []AccessGitHubJob `yaml:"github,omitempty"`
// ServiceAccounts admit one ServiceAccount on one named cluster.
ServiceAccounts []ServiceAccountMatcher `yaml:"service_accounts,omitempty"`
// AWS admits one IAM role in one registered account.
AWS []AccessAWSRole `yaml:"aws,omitempty"`
}
AccessGroup is one internal group and everything that admits a caller to it, in the order [Access.Policy] writes the matchers.
type AccessOrg ¶
type AccessOrg struct {
Org string `yaml:"org"`
Members []string `yaml:"members,omitempty"`
Teams []AccessGitHubTeam `yaml:"teams,omitempty"`
Ignore []string `yaml:"ignore,omitempty"`
}
AccessOrg is one GitHub organisation's bindings.
type AccessOverlay ¶
type AccessOverlay struct {
// Groups add matchers to a group, appended after what the access
// matrix derived. A group the matrix never produced is created with
// only these: the matrix describes people, so a workload rule has
// nothing there to attach to.
Groups map[string]OverlayGroup `yaml:"groups,omitempty"`
// Clients are declared in full. An id the access part also declares
// is refused, not merged.
Clients map[string]Client `yaml:"clients,omitempty"`
}
AccessOverlay is what an installation adds to the derived part.
type AccessPerson ¶
type AccessPerson struct {
Name string `yaml:"name"`
Addresses []string `yaml:"addresses,omitempty"`
}
AccessPerson is one person and the addresses they are known by.
type AccessResource ¶
type AccessResource struct {
ID string `yaml:"id"`
DisplayName string `yaml:"displayName,omitempty"`
Description string `yaml:"description,omitempty"`
Requires []string `yaml:"requires,omitempty"`
TTLCap Duration `yaml:"ttlCap,omitempty"`
AbsoluteCap Duration `yaml:"absoluteCap,omitempty"`
ReadOnly bool `yaml:"readOnly,omitempty"`
SigningAlg string `yaml:"signingAlg,omitempty"`
GroupsDelimiter string `yaml:"groupsDelimiter,omitempty"`
Groups GroupsOverride `yaml:"groups,omitempty"`
}
AccessResource is one resource (RFC 8707).
type AccessRole ¶
type AccessRole struct {
Name string `yaml:"name"`
Scopes []string `yaml:"scopes,omitempty"`
Implies []string `yaml:"implies,omitempty"`
}
AccessRole is one role of a thing.
type AccessScope ¶
AccessScope is one declared scope.
type AccessSlackChannel ¶
type AccessSlackChannel struct {
Name string `yaml:"name"`
Private bool `yaml:"private,omitempty"`
Mode string `yaml:"mode,omitempty"`
Adopt string `yaml:"adopt,omitempty"`
Ignore []string `yaml:"ignore,omitempty"`
From []string `yaml:"from,omitempty"`
}
AccessSlackChannel is one channel's binding.
type AccessThing ¶
type AccessThing struct {
Name string `yaml:"name"`
Scopes []string `yaml:"scopes,omitempty"`
Roles []AccessRole `yaml:"roles,omitempty"`
}
AccessThing is one declared thing and its role ladder.
type AccessVocabulary ¶
type AccessVocabulary struct {
Scopes []AccessScope `yaml:"scopes,omitempty"`
Things []AccessThing `yaml:"things,omitempty"`
}
AccessVocabulary is Vocabulary as lists.
type AccessWorkspace ¶
type AccessWorkspace struct {
Workspace string `yaml:"workspace"`
Channels []AccessSlackChannel `yaml:"channels,omitempty"`
}
AccessWorkspace is one Slack workspace.
type Client ¶
type Client struct {
// Kind is public, confidential or exchange.
Kind string `yaml:"kind"`
// DisplayName is what a person is told they are signing in to: "Sign
// in to continue to Argo CD". Optional; see [Client.Title] for what
// is shown without one.
//
// The id is the audience and is spelled for machines. A person who
// arrives on the sign-in page redirected from somewhere else needs to
// recognise where they are going, and a page that cannot say is the
// shape of a phishing page.
//
// SHOWN TO ANYONE who starts a sign-in for this client, before they
// have proved who they are. It is not a place for anything a stranger
// should not read.
DisplayName string `yaml:"display_name,omitempty"`
// Description is one line under the name on the sign-in page: what
// the application is for. Optional, and public in exactly the way
// DisplayName is.
Description string `yaml:"description,omitempty"`
// Secret is how a confidential client gets its secret: a name, which
// the installation delivers as an input (`clients/<id>/secret`), or
// `{generate: true}`, which has the issuer make one and keep it with
// its credentials. The secret itself is never in this file. See
// [ClientSecret].
Secret ClientSecret `yaml:"secret,omitempty" json:"secret,omitempty"`
// Redirects are the allowed redirect URIs.
Redirects []string `yaml:"redirects,omitempty"`
// SignedOut are the allowed landing pages after an RP-initiated
// sign-out (OIDC RP-Initiated Logout `post_logout_redirect_uri`).
//
// A separate list from Redirects on purpose. A redirect URI is where
// a code is delivered -- `/oauth2/callback`, a path that starts a
// sign-in; a post-logout URI is where a person is put down once their
// session is gone, which is the application's front page. Sending
// somebody to the callback after signing out starts the login they
// just ended, and an open-redirect check that accepts either list
// checks nothing about the difference.
SignedOut []string `yaml:"signed_out,omitempty"`
// Requires lists internal groups, any one of which admits a caller. A
// caller in none is refused before a token exists.
Requires []string `yaml:"requires,omitempty"`
// TTLCap caps the lifetime the groups would otherwise grant.
TTLCap Duration `yaml:"ttl_cap,omitempty"`
// SignInExchange lets a person's sign-in to this client be traded for
// a token for another client: `sluisctl login`, then `sluisctl
// kube-token` or `sluisctl aws`. Only the access token of a live
// session qualifies, presented by this same client, and what it opens
// is still decided by the TARGET client's `requires`.
//
// Off everywhere else: a token issued to a relying party is for that
// party. Only a public client may carry it -- a CLI on the person's own
// machine, whose tokens go nowhere but back to this issuer.
SignInExchange bool `yaml:"sign_in_exchange,omitempty"`
// BackChannelLogout is where this client is TOLD that a session it
// holds has ended (OIDC Back-Channel Logout 1.0). The issuer posts a
// signed logout token there, server to server, at the moment of
// sign-out.
//
// OPT-IN, per client, and that is what makes it safe to serve: a
// client that names no address is never contacted and behaves
// exactly as before. Nothing here forces a relying party to
// implement anything.
//
// It is the only one of the three optional logout mechanisms worth
// having. Session Management and Front-Channel both work by putting
// an iframe from this origin inside the application's page, which
// browsers now block by default; this is a POST between two servers
// and does not care what the browser allows. It is also the only one
// that can reach a proxy, which is what actually holds the session
// for a console that runs no OpenID flow of its own.
BackChannelLogout string `yaml:"backchannel_logout_uri,omitempty"`
// SigningAlg pins which algorithm a token FOR THIS CLIENT is signed
// with: an ID token always, an access token whenever this client is
// its own audience (no `resource` named). Empty uses the installation
// default -- today's one key's algorithm, ES384 in the chart. One of
// [SigningAlgs]; refused at PARSE time if it is not, and refused at
// ISSUER START if this installation configures no key for it -- see
// [Client.validate] and [issuer.NewStorage].
//
// It exists because relying parties do not all keep up: OIDC Core
// 15.1 expects a provider to be ABLE to sign with RS256, and EKS's
// associated OIDC provider and Kargo's verifier accept nothing else.
// Rather than pin the whole installation to the slowest relying
// party forever, this is the one row that asks for it.
SigningAlg string `yaml:"signing_alg,omitempty"`
// Groups overrides which of a caller's held groups a token for this
// client carries, beyond whatever [Client.Requires]'s own
// <scope>:<thing> pairs already keep -- see [Policy.ScopeGroups] and
// docs/decisions/0006-groups-claim-scoped-per-audience.md. Empty asks
// for nothing beyond that pair matching. NOT YET APPLIED to a minted
// token: `groupsScoping` governs whether this is only computed and
// logged (`report`, the only mode this release ships) or actually
// narrows a token (`enforce`, refused at load for now).
Groups GroupsOverride `yaml:"groups,omitempty"`
// GroupsDelimiter rewrites every `:` in each group name a token FOR
// THIS CLIENT carries under `groups` to this string instead -- after
// [Policy.ScopeGroups] has already decided which groups survive, never
// changing which ones do. Empty (the default) leaves `groups` exactly
// as every other audience sees it, the grant's own `:`. One of
// [Client.SigningAlg]'s shape one row over: a per-audience exception
// for a relying party this installation does not control, refused at
// PARSE time if it could ever make two of this policy's own group
// names collide -- see [validGroupsDelimiter] and
// [Policy.checkGroupsDelimiterCollision].
//
// It exists for exactly one relying party today: opkssh's server-side
// policy, `oidc:groups:<value>`, splits its own argument on EVERY `:`
// and compares only the last segment -- so it can never match a group
// named `<scope>:<thing>:<role>`, this schema's own separator, however
// it is quoted. Until that is fixed upstream, an opkssh-facing client
// may pin `groups_delimiter: "."` (or another delimiter this schema
// accepts) so its tokens carry `env.ssh.admin` instead of
// `env:ssh:admin` -- a name opkssh's own splitting can actually read.
// See docs/decisions/0015-a-per-audience-groups-delimiter-for-opkssh.md
// and docs/reference/policy.md#groups-delimiter-per-audience-opkssh-interop.
GroupsDelimiter string `yaml:"groups_delimiter,omitempty"`
// Session is the class of the refresh chains this client opens:
// [SessionInteractive] (absent) or [SessionAgent]. An agent chain is
// held to the service configuration's `lifetimes.agent` rather than to
// the installation's absolute limit, and its access and ID tokens to
// `lifetimes.agent.access`. The class is decided when an authorization
// completes and recorded on the chain, so a later change here never
// changes the class of a chain already open. Refused on an exchange
// client, which opens no chain with an `auth_time`, and as `agent`
// together with [Client.SignInExchange]. See
// docs/decisions/0040-agent-class-sessions.md.
Session string `yaml:"session,omitempty"`
}
Client is a relying party. Its id is the audience of the tokens issued for it.
func (Client) SecretGenerated ¶ added in v1.66.0
SecretGenerated reports that the issuer makes this client's secret itself (`secret: {generate: true}`) rather than being handed one.
func (Client) SecretName ¶ added in v1.66.0
SecretName is the name an installation delivers this client's secret under, or "" when the client has none or the issuer generates it.
func (Client) Title ¶
Title is what a person is shown this client as, given its id: the declared `display_name`, or for a cluster's client that declares none, "Kubernetes — <cluster>", or else the id itself.
Never a generic "the application": a page that cannot name where a person is going has told them nothing they can check, and the id, however machine-shaped, is at least the same word on every page and in every log.
type ClientDocuments ¶
type ClientDocuments struct {
// Origins are the hosts that may serve a document. A host, or a host
// and port; never a scheme, a path or a wildcard — the scheme is
// always HTTPS and a path would be a client, not an origin.
//
// Empty turns the whole mechanism off, which is the default: an
// installation that has not named a host does not accept documents.
Origins []string `yaml:"origins,omitempty"`
// Requires lists internal groups, any one of which admits a caller to
// EVERY document client. It is the same gate a declared client's
// `requires` is, and it is mandatory: turning this mechanism on
// without saying who may use it would admit every person who can sign
// in at all, which is not a decision anybody would make on purpose.
Requires []string `yaml:"requires,omitempty"`
// TTLCap caps the lifetime of a document client's tokens, as
// `ttl_cap` does on a declared client. Optional, and worth setting:
// these tokens go to software this installation did not deploy.
TTLCap Duration `yaml:"ttl_cap,omitempty"`
// Groups overrides which of a caller's held groups a token for EVERY
// document client carries, beyond whatever [ClientDocuments.Requires]'s
// own <scope>:<thing> pairs already keep -- the same key, the same
// shape and the same validation as [Client.Groups] and
// [Resource.Groups], one row over: see [Policy.ScopeGroups].
Groups GroupsOverride `yaml:"groups,omitempty"`
// Session is the class of the refresh chains EVERY document client
// opens, as `session` is on a declared client: [SessionInteractive]
// (absent) or [SessionAgent]. It is the installation's grant and never
// the document's: the issuer reads no class from a fetched document. An
// installation that needs one document client interactive and another
// agent declares one of them as a client row, and an origin admitted
// with `session: agent` must serve only documents its vendor controls,
// because every document on it gets the class. See
// docs/decisions/0040-agent-class-sessions.md.
Session string `yaml:"session,omitempty"`
}
ClientDocuments admits clients that were never declared here.
Every other client in this file is minted as code, and that is the right default: a row is reviewable, and its `requires` is where "who may obtain a token" is decided. It does not fit a client that is somebody's editor or a hosted assistant — software this installation does not deploy and cannot enumerate, arriving with the Model Context Protocol.
Such a client identifies itself with an HTTPS URL that serves a document describing it (OAuth Client ID Metadata Document). The issuer fetches the document, validates it, and treats it as a public client. Nothing is registered and nothing accumulates.
THE REASON THIS IS PROPORTIONATE, because it is the whole argument: registration is not the authorization decision here. Reach is decided by the groups a caller holds, and a client this issuer has never seen cannot widen them — it can only ask a person to consent to the reach that person already has. So the threat is not escalation. It is phishing: a hostile client persuading somebody to sign in to it and taking the token away. Which is why the guard below is an allow-list of ORIGINS rather than a refusal of unknown clients, and why ClientDocuments.Requires is mandatory rather than optional.
func (ClientDocuments) Agent ¶ added in v1.69.0
func (d ClientDocuments) Agent() bool
Agent reports whether every document client's chains are agent-class.
func (ClientDocuments) Enabled ¶
func (d ClientDocuments) Enabled() bool
Enabled reports whether any document client may be admitted.
func (ClientDocuments) Permits ¶
func (d ClientDocuments) Permits(u *url.URL) bool
Permits reports whether a document URL's host is allow-listed.
An exact host match, because a suffix match is how `claude.ai.evil` gets in and a wildcard is how somebody's forgotten subdomain does. Comparison is case-insensitive: a host is, and a client that upper-cases its own URL should not be refused for it.
type ClientSecret ¶ added in v1.66.0
type ClientSecret struct {
// Name is the input's name; empty when Generate is set.
Name string
// Generate says the issuer makes the secret.
Generate bool
}
ClientSecret is how a confidential client comes by its secret. It is one of two shapes, and never both:
- a string, `secret: grafana`, the NAME of an input the installation delivers (`clients/<id>/secret`): the value is made elsewhere and the issuer only reads it;
- an object, `secret: {generate: true}`: the issuer makes the value itself, once, and keeps it with its credentials. Nothing in the document names where it is; the client's id does.
An older binary refuses the object form (its field is a string).
func (ClientSecret) IsZero ¶ added in v1.66.0
func (s ClientSecret) IsZero() bool
IsZero reports that no secret is declared. yaml.v3's `omitempty` uses it.
func (ClientSecret) MarshalJSON ¶ added in v1.66.0
func (s ClientSecret) MarshalJSON() ([]byte, error)
MarshalJSON implements json.Marshaler.
func (ClientSecret) MarshalYAML ¶ added in v1.66.0
func (s ClientSecret) MarshalYAML() (any, error)
MarshalYAML implements yaml.Marshaler, the inverse of UnmarshalYAML.
func (*ClientSecret) UnmarshalJSON ¶ added in v1.66.0
func (s *ClientSecret) UnmarshalJSON(b []byte) error
UnmarshalJSON implements json.Unmarshaler, with the same two shapes.
func (*ClientSecret) UnmarshalYAML ¶ added in v1.66.0
func (s *ClientSecret) UnmarshalYAML(node *yaml.Node) error
UnmarshalYAML implements yaml.Unmarshaler: a scalar names an input, a mapping is `{generate: true}`.
type ClientView ¶
ClientView is one declared client as the console shows it.
type Duration ¶
Duration is a time.Duration that reads Go duration strings from YAML, because a policy file is read by people.
func (Duration) MarshalYAML ¶
MarshalYAML implements yaml.Marshaler.
type Fragment ¶
Fragment is what a group adds to a token: an arbitrary claim shape, deep-merged with every other group's.
type GitHubClaims ¶
type GitHubClaims struct {
Repository string
Owner string
Ref string
Workflow string
Environment string
// Visibility is the repository's visibility: public, private or
// internal.
Visibility string
// WorkflowRef is the workflow file the run was started from, at the
// ref it was read at: `org/repo/.github/workflows/release.yml@refs/heads/main`.
WorkflowRef string
// JobWorkflowRef is the workflow file the JOB is defined in. It
// differs from WorkflowRef in a reusable workflow, and it is the one
// that says which code holds the token.
JobWorkflowRef string
// SHA is the commit the run is for.
SHA string
// EventName is what started the run: push, pull_request,
// workflow_dispatch...
EventName string
// RefType is branch or tag.
RefType string
}
GitHubClaims are a CI identity token's verified claims.
type GitHubMatcher ¶
type GitHubMatcher struct {
Repository string `yaml:"repository,omitempty"`
Owner string `yaml:"owner,omitempty"`
Ref string `yaml:"ref,omitempty"`
Workflow string `yaml:"workflow,omitempty"`
Environment string `yaml:"environment,omitempty"`
// Visibility is the repository's: public, private or internal, as
// GitHub states it in the token. `{owner: org, visibility: private}`
// is every private repository of an organisation — which a fork's run
// is not, because a fork is another repository.
Visibility string `yaml:"visibility,omitempty"`
// WorkflowRef and JobWorkflowRef pin the workflow FILE a run was
// started from and the one its job is defined in, each at its ref:
// `org/repo/.github/workflows/release.yml@refs/heads/main`. A
// repository and a branch admit every workflow on that branch; one
// of these admits the one file somebody reviewed. Globs, like the
// rest, where `*` does not cross a `/`.
WorkflowRef string `yaml:"workflow_ref,omitempty"`
JobWorkflowRef string `yaml:"job_workflow_ref,omitempty"`
// SHA is the commit, for a rule that admits exactly one.
SHA string `yaml:"sha,omitempty"`
// EventName is what started the run: `push`, `workflow_dispatch`...
// Pinning it keeps a `pull_request` run of the same file out.
EventName string `yaml:"event_name,omitempty"`
// RefType is `branch` or `tag`.
RefType string `yaml:"ref_type,omitempty"`
}
GitHubMatcher matches a CI identity token.
type GitHubOrg ¶
type GitHubOrg struct {
// Members are internal groups whose holders belong in the
// organisation itself. Being in a bound team implies organisation
// membership, so this is for the people who should be members
// without a team — and it is what keeps them from being removed as
// somebody no binding accounts for.
Members []string `yaml:"members,omitempty"`
// Teams are the organisation's teams, keyed by SLUG rather than
// display name: the slug is what the API takes and what a rename
// leaves alone.
Teams map[string]GitHubTeam `yaml:"teams,omitempty"`
// Ignore are work addresses and GitHub logins the controller leaves
// alone in this organisation, whatever the bindings say: an address in
// a bound directory group that nobody can take out of it, a temporary
// owner. An ignored address is never wanted here; an ignored login is
// never added, removed or changed, linked or not. Removing the line
// brings them back under the bindings.
Ignore []string `yaml:"ignore,omitempty"`
}
GitHubOrg is one organisation's bindings.
func (GitHubOrg) IgnoredAddresses ¶
IgnoredAddresses are the ignored work addresses, lowercased.
func (GitHubOrg) IgnoredLogins ¶
IgnoredLogins are the ignored GitHub logins, lowercased.
type GitHubTeam ¶
type GitHubTeam struct {
// Members are internal groups whose holders belong in the team.
Members []string `yaml:"members,omitempty"`
// Maintainers are internal groups whose holders maintain it.
Maintainers []string `yaml:"maintainers,omitempty"`
}
GitHubTeam is one team's binding. GitHub has two team roles and both are declared here; a holder of a maintainer group is a maintainer even when a member group also names them, because the wider role is the one they were given.
func (GitHubTeam) Groups ¶
func (t GitHubTeam) Groups() []string
Groups returns every internal group the team binds, members and maintainers together, sorted and without repeats.
type Group ¶
type Group struct {
// Members are directory group addresses, from any connected
// workspace. A caller is in this group when the directory confirms,
// authoritatively, that its account is in one of them.
Members []string `yaml:"members,omitempty"`
// Matchers are conditions on a verified proof. A machine is in a
// group because its CI token or ServiceAccount matches; a person can
// be too, by address or domain, which is the escape hatch for the day
// before any directory group exists.
Matchers []Matcher `yaml:"matchers,omitempty"`
}
Group is one internal group: a set of directory groups whose members are in it, and matchers a proof can satisfy to be in it.
type GroupView ¶
type GroupView struct {
Name string
Members []Member
Matchers []string
Rules []MatcherView
Claims Fragment
Lifetime time.Duration
}
GroupView is one internal group as an operator sees it: who is in it, what it adds, and how long it makes a token live.
type GroupsOverride ¶
type GroupsOverride struct {
// All carries every held group, exactly as every token does today.
// Written as `groups: all`.
All bool
// Things additionally carries every held group beyond whatever
// requires-pair matching already keeps, one entry at a time, written
// as `groups: [entry, entry, ...]`. Each entry is one of three
// shapes, tried in this order against a held name:
//
// - the held name's THING (in ANY scope) -- `grafana` keeps
// `devel:grafana:viewer` and `prod:grafana:admin` alike;
// - for a two-segment name (`rung:<name>`, `emp:<slug>`, neither of
// which is a grant and so has no thing to name), its FAMILY --
// `emp` keeps every `emp:<slug>` held, `rung` every `rung:<name>`
// held, the same way a thing keeps every role of it;
// - the held name OUTRIGHT, in full (`rung:sre`), for one specific
// two-segment name rather than every one of its family.
//
// See [Policy.ScopeGroups] for exactly how a held name is matched
// against these, and [Policy.checkGroupsOverride] for what an entry
// must look like to validate.
Things []string
}
GroupsOverride is a client's or a resource's declaration that its token should carry more of a caller's held groups than requires-pair matching alone would keep -- the override Policy.ScopeGroups documents, and the key docs/decisions/0006-groups-claim-scoped-per-audience.md left this schema to name.
The zero value asks for nothing beyond requires-pair matching, which is what every client and resource declared before this existed keeps getting.
func (GroupsOverride) MarshalYAML ¶
func (g GroupsOverride) MarshalYAML() (any, error)
MarshalYAML implements yaml.Marshaler, the inverse of GroupsOverride.UnmarshalYAML: "all" is written back as the scalar and a list of things as a sequence. The zero value is never actually asked to marshal itself -- yaml.v3's `omitempty` zero-checks an exported struct field by its own exported fields when the type declares no `IsZero() bool`, finds both zero, and omits the `groups` key entirely -- so a client or resource declaring no override renders exactly as it did before this field existed, which is what keeps Policy.Digest stable for every policy written before this key.
func (*GroupsOverride) UnmarshalYAML ¶
func (g *GroupsOverride) UnmarshalYAML(node *yaml.Node) error
UnmarshalYAML implements yaml.Unmarshaler. The YAML shape is either the scalar "all" or a sequence of thing names -- never both, so the two cannot disagree about which was meant.
type Held ¶
type Held struct {
// Group is the internal group's name — always concrete, even when it
// was reached through a mapping wildcard or through inheritance.
Group string
// Via names what put the caller in it: the directory groups matched,
// or the matcher, rendered for a person to read. Empty when Group was
// reached only through inheritance (Implies is set instead).
Via []string
// Key is the Groups-table key that matched directly — equal to Group
// for an ordinary key, and different for a mapping wildcard: matching
// `*:k8s:admin` for `devel:k8s:admin` records Key="*:k8s:admin". Empty
// when Group was reached only through inheritance.
Key string
// Implies is the concrete group whose role implied this one, one hop
// at a time, per [Vocabulary]'s declared implies graph: holding
// `devel:k8s:admin` where admin implies operator implies viewer
// produces a `devel:k8s:viewer` [Held] with Implies="devel:k8s:operator",
// not "…admin" — the direct parent, so a reader (the console,
// eventually) walks the chain one link at a time rather than being
// handed a root it has to trust. Empty when Group was held directly.
Implies string
}
Held is one internal group a caller is in, and why.
A caller may hold one Held.Group for more than one reason at once — a concrete Groups-table key AND a mapping wildcard that also reaches it, or a direct hold AND an implied one — and every reason is its own Held entry rather than one entry picking a winner: explainability means showing all of them, not the first one found.
type Input ¶
type Input struct {
// Email of the signed-in account, when the proof is a sign-in.
Email string
// DirectoryGroups the hub reports for that account.
DirectoryGroups []string
// Authoritative reports whether the hub's answer may be acted on.
// Membership never grants anything without it.
Authoritative bool
// GitHub carries a CI identity token's verified claims.
GitHub *GitHubClaims
// ServiceAccount carries a verified Kubernetes ServiceAccount.
ServiceAccount *ServiceAccountRef
// AWS carries a verified AWS IAM role.
AWS *AWSRole
}
Input is one verified proof, as far as the policy is concerned. Everything in it has already been checked by the caller: the policy decides only what a verified identity is entitled to.
type Matcher ¶
type Matcher struct {
// GitHub matches a CI identity token's claims. Every set field must
// match; values are glob patterns in the sense of path.Match.
GitHub *GitHubMatcher `yaml:"github,omitempty"`
// ServiceAccount matches a Kubernetes ServiceAccount exactly.
ServiceAccount *ServiceAccountMatcher `yaml:"service_account,omitempty"`
// AWS matches an AWS IAM role that proved itself with its outbound
// identity federation token.
AWS *AWSMatcher `yaml:"aws,omitempty"`
// Email matches one signed-in address, case-insensitively.
Email string `yaml:"email,omitempty"`
// EmailDomain matches every signed-in address in a domain.
EmailDomain string `yaml:"email_domain,omitempty"`
}
Matcher is one condition on a verified proof. Exactly one field is set.
type MatcherView ¶
MatcherView is one declared rule, structured for a list.
type Member ¶
type Member struct {
Address string
}
Member is one directory group in an internal group.
There used to be a Layer here, saying whether the deployment declared it or an operator added it in the console. There is one layer now: who is in which internal group is the policy, rendered from the installation's own access model and reviewed in git, and nothing else. A console that could disagree with git was a second source of truth and a merge to reconcile them.
type OrgView ¶
type OrgView struct {
Org string
Members []string
// Ignore are the addresses and logins the controller leaves alone.
Ignore []string
}
OrgView is one organisation's own binding: the internal groups whose holders belong in the organisation with or without a team.
type OverlayGroup ¶
type OverlayGroup struct {
Matchers []Matcher `yaml:"matchers,omitempty"`
}
OverlayGroup is the matchers an overlay adds to one group.
type Policy ¶
type Policy struct {
// Version is the schema's version. 1 is the only one; an unknown
// version is refused rather than guessed at.
Version int `yaml:"version"`
// Vocabulary declares which scopes, things and roles a grant name may
// use, and each thing's role ladder. OPTIONAL: nil means what it
// always meant — any `<scope>:<thing>:<role>` spelling is accepted,
// unchecked. Declared, it makes every concrete grant anywhere in this
// file (see [Policy.checkGrantName]) and every mapping wildcard in
// [Policy.Groups] (see [Policy.checkGroupKey]) a claim the loader can
// check instead of trust. See docs/reference/policy.md#vocabulary and
// docs/reference/taxonomy.md.
Vocabulary *Vocabulary `yaml:"vocabulary,omitempty"`
// Groups is every internal group name an installation uses, and how a
// caller comes to be in it. A key may be a mapping wildcard — `*` in
// the scope and/or thing position — when [Policy.Vocabulary] is
// declared; see [Policy.checkGroupKey].
Groups map[string]Group `yaml:"groups,omitempty"`
// Claims is what a group adds to a token. Sparse: a group that adds
// only its own name is absent here.
Claims map[string]Fragment `yaml:"claims,omitempty"`
// Lifetimes is how long a token lives, keyed by group, plus the
// "default" key. The shortest across a caller's groups wins.
Lifetimes map[string]Duration `yaml:"lifetimes,omitempty"`
// Resources are what a token may be minted FOR, when that is not the
// client asking. Keyed by the resource indicator a client sends as
// `resource`; see [Resource] for when the two differ.
Resources map[string]Resource `yaml:"resources,omitempty"`
// ClientDocuments admits clients that are not declared here at all,
// by fetching a document they serve about themselves. Off unless it
// names an origin; see [ClientDocuments] for why that is safe here.
ClientDocuments ClientDocuments `yaml:"client_documents,omitempty"`
// Clients is who may be issued a token for what. A client's id is the
// audience.
Clients map[string]Client `yaml:"clients,omitempty"`
// GitHub binds internal groups to GitHub teams, keyed by the
// organisation's login. It grants nothing here and appears
// in no token: a controller reads it and makes each organisation's
// membership match.
//
// It lives in this file for one reason — a reader of the access model
// sees every GitHub team's source without opening another file — and
// a team is a consumer of a group exactly as a client's `requires`
// is: the holders of these groups are the people that team should
// contain. Which accounts hold a group is a question only the
// directory answers, so nothing about a provider appears here.
GitHub map[string]GitHubOrg `yaml:"github,omitempty"`
// People says which addresses are the same person — someone with an
// address in two companies' domains — keyed by a name we choose:
// `jdoe: [j.doe@acme.example, john@globex.example]`.
//
// It is generic, not Slack's: any reconciler that looks a person up by
// their address in one domain and might hold them under another
// benefits. And it only LINKS addresses. It never says who holds a
// group — the directory alone answers that — so a person listed here
// gains nothing until the directory puts one of their addresses in a
// group, and is not removed from anything by being left out. See
// [Policy.PeopleByAddress].
People map[string][]string `yaml:"people,omitempty"`
// Slack binds internal groups to Slack channels across one or more
// workspaces. It grants nothing and appears in no token; a controller
// that reads it is being built, and until it ships nothing does.
// See [Slack].
Slack Slack `yaml:"slack,omitempty"`
}
Policy is one layer of the schema.
func LoadDeclared ¶
LoadDeclared reads the declared layer from a file or a directory. Every YAML file in a directory is one layer and they merge, so a deployment can render one file per source — clusters, cloud accounts, static apps — instead of one document nobody can review. A file with an `access` key is an AccessDocument and is reshaped into a layer first.
func Parse ¶
Parse reads one layer and checks its shape. Unknown keys are an error: a renamed field must fail a rollout, not a login.
func ParseAccess ¶
ParseAccess reads one access document and returns the layer it stands for. Unknown keys are an error, as in Parse.
func ParseLayer ¶ added in v1.62.0
ParseLayer reads one declared layer: a policy file, or an access document, which is reshaped into one (ParseAccess).
func (Policy) Digest ¶
Digest is a stable name for a declared policy: the first twelve bytes of the SHA-256 of its canonical YAML, whose map keys are always sorted.
func (Policy) Evaluate ¶
Evaluate resolves a proof into groups, claims and a lifetime.
Membership through the directory needs an authoritative answer: membership the hub cannot vouch for must not grant anything new. Matchers do not, because a CI token or a signed-in address is verified on its own.
Two things happen here, and only here, so that every reader downstream — a client's or resource's `requires`, a token's `groups` claim, GitHub team reconciliation reading who holds a bound group — sees the result without knowing either exists:
- A Groups-table key that is a mapping wildcard (Policy.Vocabulary) stands for every concrete grant its expansion names; a caller matched by the key holds all of them, unioned with whatever concrete keys separately match.
- A concrete grant `S:T:R` held by either route also holds every role `R` implies, transitively, on the SAME `S:T` — never across scopes.
func (*Policy) Merge ¶ added in v1.62.0
Merge folds one more layer into p, by the rules a directory of layers is merged by: every table by key, and a key declared twice is an error naming from. It is what a renderer layering a policy out of several sources calls; a process loads the one document a render wrote.
func (Policy) PeopleByAddress ¶
PeopleByAddress maps every listed address, normalised, to the key of the person it belongs to. It is what a reconciler uses to find one person by whichever of their addresses is in the domain it is looking in. See Policy.People for what the table does and does not say.
func (Policy) ScopeGroups ¶
ScopeGroups computes which of a caller's held internal groups a token for audience would carry under per-audience scoping (docs/decisions/0006-groups-claim-scoped-per-audience.md, refined by docs/decisions/0010-a-declared-vocabulary.md), WITHOUT applying it: this release computes and logs the answer (`groupsScoping: report`) and mints the token exactly as before; an `enforce` mode that actually narrows a token's `groups` claim ships later.
audience is a client id, or the RFC 8707 resource a request named (see docs/reference/policy.md#resources--what-a-token-is-for) -- for a token exchange, the audience the exchange was GRANTED, never the id of the client presenting it.
held is every internal group Policy.Evaluate returned in its Result.Groups -- after the vocabulary's inheritance closure, which Evaluate has already run. ScopeGroups does not repeat that walk; it only decides which of the names it is handed a token would keep.
A group is kept when EITHER:
- its <scope>:<thing> pair matches one of the audience's `requires` pairs, in ANY role. A client requiring `devel:grafana:viewer` keeps a held `devel:grafana:editor` too, because the pair is what is checked, never the role -- exactly the example docs/decisions/0006-groups-claim-scoped-per-audience.md gives; or
- the audience's `groups` override asks for it: `all` keeps everything held, and a list of thing names keeps every held group of one of those things, in ANY scope, beyond whatever requires-pair matching already kept.
A held name that is not a concrete three-segment grant -- a `rung:` or an `emp:` name, neither of which is a role on a thing -- has no <scope>:<thing> pair to match, so requires-pair matching never keeps it. It is kept only when the override's thing list names it OUTRIGHT, by its own full name (`groups: [rung:sre]`), which is what "they're carried only if an override asks" means for a name with no thing to list. Nothing about EVALUATING them changes: a rung's lifetime, in particular, is read by [Policy.lifetimeOf] off the FULL held list Policy.Evaluate returns, before this function ever runs, so a `rung:` group this function drops from a token still shortens that token's life exactly as it does today. Scoping narrows what a token SAYS, never what the issuer computes from what a caller holds.
A self-described client -- one admitted by URL through Policy.ClientDocuments rather than a row of its own -- IS gated, by `client_documents.requires`, which every such client shares; see [Policy.audienceScope]. Its pairs and its `groups` override, if it declares one, are read from there.
An audience [Policy.audienceScope] cannot place at all -- not a declared client, not a declared resource, and not a URL Policy.ClientDocuments permits (including every audience when this installation admits no document client) -- has no requires pairs this function can read and no override it could have written, so every held grant is dropped. That is the plain reading of the decided rule: "the groups its client's or its resource's requires names" presupposes a gate, and a caller of this function that cannot find one has nothing to keep by. It is also the useful reading for report mode, whose whole purpose is to surface exactly this: an audience minting tokens today that this function cannot place under any gate is the signal that it needs one, before enforce mode could narrow anything for it correctly.
kept and dropped partition held: every name in held is in exactly one of the two, both sorted, and both nil when held is empty.
func (Policy) Unconsumed ¶
Unconsumed returns the sorted names of internal groups that are declared in Policy.Groups but referenced by none of:
- any client's Client.Requires
- any resource's Resource.Requires
- ClientDocuments.Requires
- any client's, resource's or ClientDocuments's GroupsOverride (the `groups:` key: Client.Groups, Resource.Groups, ClientDocuments.Groups) — see below
- any GitHub binding (organisation GitHubOrg.Members or team GitHubTeam.Members/GitHubTeam.Maintainers)
- any Slack binding (a channel's SlackChannel.From)
- this hub's own roles (groups whose [thing] segment is ThingSelf and whose role is RoleOperator or RoleViewer, which the hub reads directly from the token)
- catalogueGroups — see below
A group referenced only by Policy.Claims or Policy.Lifetimes keys is still unconsumed: claims and lifetimes decorate a group and do not consume it; they add to a token only when a caller already holds the group for some other reason. Groups with the [rung] or [emp] prefix are never reported, because they are not grants and exist for other purposes.
A GroupsOverride is judged by the SAME rule Policy.ScopeGroups applies to a live token, via [Policy.groupsOverrideKeeps]: `groups: all` keeps — and so consumes — every declared group, and `groups: [thing, ...]` keeps every declared group whose own thing or family one of those names, REGARDLESS OF SCOPE, because that is exactly how widely the override reaches at request time. This used to be this function's own KNOWN LIMITATION: docs/decisions/0006-groups-claim-scoped-per-audience.md gave a client a way to read groups beyond its Requires pairs without this lint ever learning of it, so a policy using that schema legitimately saw a warning for every group it reached only that way. Deciding it with the same function ScopeGroups itself calls means the two can never quietly disagree about what one override reaches.
catalogueGroups are the groups a GitHub App catalogue's grants name — see internal/githubapp/catalogue.Grant.Group and internal/issuer/githubtoken.go's [DecideGitHubGrant], which is the thing that actually spends one: a caller presents a proof, the groups it resolves to are matched against each App's grants by this SAME string, and a match is what lets that caller ask GitHub for an installation token. A group declared for no other reason than to appear in one such grant is consumed exactly as one named in a client's Requires is — the catalogue is simply a file this package cannot read for itself: it is a deployment's own configuration, loaded by whichever process keeps a [GitHubApps] catalogue, and passing its grants' groups here is how that caller tells this lint about a consumer this package cannot see on its own. Nothing is assumed about the catalogue's shape here: a caller with no catalogue, or one that has not wired this yet, passes nothing, and every group a catalogue would otherwise explain keeps being reported — which is the false positive this parameter exists to close, not a new failure mode it introduces.
This is a warning rather than an error because an installation may legitimately declare a group ahead of a client or resource that will use it, and a deployment's rollout should not wait for every reference to exist.
A mapping wildcard key (Policy.Vocabulary) is judged by its EXPANSION: `*:k8s:admin` counts as consumed the moment any one of the concrete grants it maps to — `devel:k8s:admin`, `stage:k8s:admin`, and so on — is itself consumed by something, because that is the same grant reaching a caller through a shorter key. catalogueGroups is checked by the same rule as every other consumer: a grant naming the wildcard key's own concrete expansion counts, exactly as a client's Requires naming it would, because [DecideGitHubGrant] only ever compares a grant's Group against a caller's already-resolved, always concrete groups — a catalogue can no more grant a bare mapping wildcard to anyone than a caller can hold one.
func (Policy) UnconventionalGroups ¶
UnconventionalGroups are the declared group names that are neither a grant (`<scope>:<thing>:<role>`) nor one of the two families that deliberately are not grants (`rung:<name>`, `emp:<slug>`), sorted.
It is a WARNING and not a validation error, on purpose. A name is only a convention: the policy works with any of them, relying parties bind what the token carries, and an installation mid-rename legitimately holds both shapes at once. What the convention buys is that a reader can tell a grant from an identity by looking, and that is worth saying out loud at load — where an operator sees it — rather than never.
type Resource ¶
type Resource struct {
// DisplayName is what a person is told they are granting access to.
// Shown on the sign-in page beside the client's own name, and public
// in the same way.
DisplayName string `yaml:"display_name,omitempty"`
// Description is one line under it.
Description string `yaml:"description,omitempty"`
// Requires lists internal groups, any one of which admits a caller to
// this resource. Mandatory, and checked in addition to the client's:
// the client says who is asking, this says what may be asked for.
Requires []string `yaml:"requires,omitempty"`
// TTLCap caps the lifetime of tokens minted for this resource, as
// `ttl_cap` does on a client. The shorter of the two applies.
TTLCap Duration `yaml:"ttl_cap,omitempty"`
// AbsoluteCap is the absolute session limit for a session whose
// tokens are FOR this resource, in place of the installation's
// `lifetimes.absolute`. Shorter than that is always allowed. LONGER
// is allowed only when [Resource.ReadOnly] says the resource cannot
// change anything, and never beyond [MaxAbsoluteCap]. Unset means
// the installation's own limit. See [EffectiveAbsolute] for how it
// combines when one session has touched several resources.
//
// The lengthening half is deprecated
// (docs/decisions/0040-agent-class-sessions.md): a client that needs a
// longer chain says `session: agent`. It is still honoured, with a
// warning at start ([Resource.LengthensAbsolute]). For an agent-class
// chain the cap is only ever a ceiling ([AgentAbsolute]).
AbsoluteCap Duration `yaml:"absolute_cap,omitempty"`
// ReadOnly declares that this resource only reads: a token for it
// can observe the estate and cannot change it. It is the one thing
// that lets [Resource.AbsoluteCap] exceed `lifetimes.absolute`, and
// it is a claim the owner of the resource makes, not one the issuer
// can verify -- which is why it is written down in the policy next to
// the cap rather than inferred.
ReadOnly bool `yaml:"read_only,omitempty"`
// SigningAlg pins which algorithm an ACCESS token minted for this
// resource is signed with -- an ID token never names a resource as
// its audience, so this has no say over one. Empty uses the
// installation default. See [Client.SigningAlg], which is the same
// idea one row over: a resource's audience is a service just as a
// client's is, and the two share one vocabulary of algorithms.
SigningAlg string `yaml:"signing_alg,omitempty"`
// Groups overrides which of a caller's held groups a token for this
// resource carries, beyond whatever [Resource.Requires]'s own
// <scope>:<thing> pairs already keep. See [Client.Groups], the same
// idea one row over, and [Policy.ScopeGroups].
Groups GroupsOverride `yaml:"groups,omitempty"`
// GroupsDelimiter rewrites every `:` in each group name an ACCESS
// token minted for this resource carries under `groups` -- an ID token
// never names a resource as its audience, so this has no say over one.
// See [Client.GroupsDelimiter], the same idea one row over, and
// [validGroupsDelimiter] for what is refused and why.
GroupsDelimiter string `yaml:"groups_delimiter,omitempty"`
}
Resource is something a token may be minted FOR, as distinct from the client that asks for it.
Every other audience in this file is a client's id, because until now the two were the same thing: a person signs in to Argo CD, and the token is for Argo CD. That holds wherever the application a person reaches and the software asking on their behalf are one.
It stops holding the moment they are not. A Model Context Protocol client is somebody's editor; what it wants a token for is a service somewhere else. Minting `aud` as the client's id there says "this token is for the editor", which is not true and not useful: a service pinning `aud` to decide whether a token was meant for it would have to pin the name of every editor that might call.
So a resource is declared, a client asks for it by name with the `resource` parameter (RFC 8707), and `aud` is the resource. The two gates then compose, which is the point: Client.Requires says who may use this client, and Resource.Requires says who may reach this service, and a caller has to satisfy both.
func (Resource) AbsoluteFor ¶
AbsoluteFor is this resource's own absolute session limit given the installation's: the cap when there is one, the installation's otherwise.
A cap longer than the installation's counts only for a read-only resource, and never past MaxAbsoluteCap. Resource.CheckAbsoluteCap and validation refuse such a row at load; this is the same rule as the last line of defence, so a policy swapped in later cannot lengthen a session by being wrong.
func (Resource) CheckAbsoluteCap ¶
CheckAbsoluteCap refuses a cap that would lengthen the installation's absolute limit on a resource that has not said it is read-only.
It is separate from validate because the limit it compares against is not in a policy: it is `lifetimes.absolute`, which the issuer reads from its own configuration. The issuer calls this for every resource at start, so a row that could never have been honoured stops the service instead of being quietly clamped.
func (Resource) LengthensAbsolute ¶ added in v1.69.0
LengthensAbsolute reports whether this resource's `absolute_cap` makes a chain longer than the installation's `lifetimes.absolute`: the read-only exception of docs/decisions/0033-a-longer-absolute-limit-for-read-only-resources.md, which docs/decisions/0040-agent-class-sessions.md deprecates in favour of `session: agent` on the clients that need a longer chain. Still honoured in this release; the service warns at start for every row it is true of.
type ResourceView ¶
ResourceView is one declared resource as an operator, or a startup check, sees it.
type Result ¶
type Result struct {
// Groups are the internal groups held, sorted.
Groups []string
// Held carries the same, with the reason for each.
Held []Held
// Claims is the deep merge of the held groups' fragments, with the
// group names themselves added under "groups".
Claims map[string]any
// Lifetime is the shortest across the held groups, or the default.
Lifetime time.Duration
}
Result is what the policy grants a proof.
type RoleSpec ¶
type RoleSpec struct {
// Implies are the roles this role directly implies — see
// [ThingSpec.Roles].
Implies []string `yaml:"implies,omitempty"`
// Scopes restricts this role to some of its thing's declared scopes.
// Empty (the default, and the only thing the plain-list form can
// mean) means the role is valid on every scope the thing declares. A
// non-empty Scopes must be a subset of the thing's own — see
// [Vocabulary.validate] — and a concrete grant naming a scope this
// role does not list is refused, exactly like a scope the THING does
// not list; see [Vocabulary.fits].
Scopes []string `yaml:"scopes,omitempty"`
}
RoleSpec is one declared role: the roles it directly implies, and, optionally, the subset of its thing's scopes it is itself valid on.
Its YAML value may be EITHER the plain list of implied roles this schema has always accepted (`admin: [operator]`, `viewer: []`), OR an object naming `implies` and/or `scopes` explicitly (`user: {scopes: [devel]}`), for a role that belongs to only some of its thing's declared scopes rather than all of them. Every policy written before per-role scoping existed used the first form, and RoleSpec.UnmarshalYAML keeps reading it exactly the same way.
func (RoleSpec) MarshalYAML ¶
MarshalYAML implements yaml.Marshaler, the inverse of RoleSpec.UnmarshalYAML: a role restricted to no particular scope marshals back to the plain list, exactly the shape every role had before this field existed, so Policy.Digest does not change for a policy that declares no per-role scoping. Only a role that DOES restrict its scopes marshals as the object form.
func (*RoleSpec) UnmarshalYAML ¶
UnmarshalYAML implements yaml.Unmarshaler. A sequence node is the existing implies-only shorthand; a mapping node is the object form, decoded through a plain struct so a stray key is refused by strict decoding the same way the rest of this schema refuses one.
type ScopeSpec ¶
type ScopeSpec struct {
// Sensitive marks a scope a mapping wildcard must never reach —
// `staging` and `prod`, typically. A wildcard key still expands freely
// across every OTHER scope a thing declares; see [Vocabulary.expand].
// It has no effect on a concrete grant, which names its scope outright
// and is checked like any other.
Sensitive bool `yaml:"sensitive,omitempty"`
}
ScopeSpec is one declared scope.
type ServiceAccountMatcher ¶
type ServiceAccountMatcher struct {
// Cluster narrows the rule to one cluster's account. Empty matches
// any, which is what every rule written before clusters were named
// means — and what a single-cluster installation wants.
Cluster string `yaml:"cluster,omitempty"`
Namespace string `yaml:"namespace"`
Name string `yaml:"name"`
}
ServiceAccountMatcher matches a Kubernetes ServiceAccount.
type ServiceAccountRef ¶
type ServiceAccountRef struct {
// Cluster names which cluster's API server vouched for it. The same
// namespace and name exist on every cluster, so without it two
// different machines are one subject — which is the collision `sub`
// exists to prevent. Empty where a deployment has not named its
// cluster, and empty in a matcher means any.
Cluster string
Namespace string
Name string
}
ServiceAccountRef is a verified Kubernetes ServiceAccount.
func ParseServiceAccountSubject ¶
func ParseServiceAccountSubject(subject string) (ServiceAccountRef, bool)
ParseServiceAccountSubject reads every spelling this estate has minted for a ServiceAccount, and is the only place that knows there is more than one.
Three exist because they arrived from different directions: the API server's own `system:serviceaccount:<ns>:<name>`, which a recovery sign-in completed as; this issuer's older `k8s:<ns>:<name>`, from a token exchange; and the cluster-qualified form above. A reader that knows one of them refuses a token minted by a release either side of its own, so they are all read here and only the last is written.
func (ServiceAccountRef) Subject ¶
func (r ServiceAccountRef) Subject() string
Subject is how a ServiceAccount is spelled as a token's `sub`: `<cluster>:k8s:<namespace>:<name>`, scope first like every group name.
A ref with no cluster renders the older three-part form, so an installation that has not named its cluster keeps the subjects it already mints.
type Set ¶
type Set struct {
// contains filtered or unexported fields
}
Set is the policy in force. One layer: what the deployment declared.
It stays a type of its own rather than a bare Policy because it is read concurrently by every request while a rollout may be replacing it, and because the views the console reads are shaped here.
func (*Set) AgentAbsolute ¶ added in v1.69.0
AgentAbsolute is the absolute limit of an agent-class chain that has been used for the given resources, against the class's own limit. See AgentAbsolute.
func (*Set) BrowserFacingAgents ¶ added in v1.69.0
BrowserFacingAgents lists, sorted, the declared clients that say `session: agent` and also declare `signed_out` or `backchannel_logout_uri`, which describe an application a person uses in a browser. Not refused: the service warns at start, because an agent class there is more likely a mistake than a choice.
func (*Set) ClientDocuments ¶
func (s *Set) ClientDocuments() ClientDocuments
ClientDocuments returns the document-client policy in force.
func (*Set) Clients ¶
func (s *Set) Clients() []ClientView
Clients returns every declared client, sorted by id.
func (*Set) Declared ¶
Declared is the declared policy in force, for a caller that validates a definition of its own against it (a Slack Connect channel's). Read only: its maps are the set's.
func (*Set) Digest ¶
Digest names the policy in force. Two processes that loaded the same policy agree on it; during a rollout, when one has the new policy and the other the old, they do not — and an answer computed under a policy the asker did not decide with must not be acted on.
func (*Set) EffectiveAbsolute ¶
EffectiveAbsolute is the absolute session limit for a refresh chain that has been used for the given resources, against the installation's own `lifetimes.absolute`. See EffectiveAbsolute.
func (*Set) GitHubOrgs ¶
GitHubOrgs returns every organisation-level binding, sorted by organisation. An organisation that binds only teams is absent: there is nothing to say about it that the team rows do not.
func (*Set) GitHubTeams ¶
GitHubTeams returns every team binding, sorted by organisation then team.
It is read by the console's Rules page, which is the point of the table living in the policy at all: *who is in this GitHub team, and why* is answered by reading the access model rather than by opening GitHub.
func (*Set) Resources ¶
func (s *Set) Resources() []ResourceView
Resources returns every declared resource, sorted by id. It exists beside Set.Resource for the same reason Set.Clients exists beside Set.Client: a caller that has to check EVERY row -- the issuer's own signing_alg-against-configured-keys refusal at start, chiefly -- needs the list, not a lookup.
func (*Set) ScopeGroups ¶
ScopeGroups reports which of held a token for audience would carry under per-audience scoping -- see Policy.ScopeGroups. Report mode calls this to log what enforce mode would later drop; nothing here changes a minted token.
func (*Set) SlackWorkspaceDeclared ¶
SlackWorkspaceDeclared reports whether the policy names a workspace key. The key is all the policy knows of a workspace: its team, its owning directory and its domains are recorded when it is connected, never declared here.
func (*Set) SlackWorkspaceKeys ¶
SlackWorkspaceKeys returns every declared Slack workspace key, sorted.
type Slack ¶
type Slack struct {
// Workspaces are the Slack workspaces, keyed by a name WE choose —
// the workspace's own name is the operator's to change, so nothing
// here depends on it. Each is connected by its own app, and so by its
// own bot token, which is never written in this file.
Workspaces map[string]SlackWorkspace `yaml:"workspaces,omitempty"`
}
Slack binds internal groups to Slack channels, in one or more workspaces. Like Policy.GitHub it grants nothing here and appears in no token: a controller reads it and makes each channel's membership match the people who hold the bound groups.
It lives in this file for the reason GitHub's bindings do — a reader of the access model sees every channel's source without opening another file — and a channel is a consumer of a group exactly as a GitHub team or a client's `requires` is. Which accounts hold a group is a question only the directory answers, so nothing about a Slack account appears here; the only Slack-specific facts are the workspace's key and the channels bound in it. Which team the key stands for, which directory owns it and which domains its people use are not policy: sluis records them when the workspace is connected and reads them from the directory.
The Slack controller reads these keys and acts on them: it creates or adopts each bound channel and keeps its members in step with the groups that are bound to it, in the workspaces it is told to act in.
type SlackChannel ¶
type SlackChannel struct {
// Private makes the channel private when the controller creates it.
// For an adopted channel it must agree with the channel as it exists;
// a disagreement is a hold at reconcile time, never a silent change
// of visibility.
Private bool `yaml:"private,omitempty"`
// Mode is `extend` (the default) or `strict`; see above.
Mode string `yaml:"mode,omitempty"`
// Ignore lists people a strict channel never removes: addresses, or
// Slack user ids (`U0123ABCD`) for somebody with no address here.
// Only meaningful, and only allowed, with `mode: strict`.
Ignore []string `yaml:"ignore,omitempty"`
// From are the internal groups whose holders belong in the channel.
// At least one: a channel fed by nothing would be a channel the
// controller empties, and that is not something to express by leaving
// a list out.
From []string `yaml:"from,omitempty"`
// Adopt is the ID (`C0123ABCD`, or `G…` for an older private channel)
// of an existing channel to adopt, optionally. A channel with the
// declared name is adopted by name without it; the ID is for a
// renamed channel, or when two channels are candidates, and by ID
// because a name can be changed or reused and an ID cannot. One ID may
// be adopted once per workspace.
Adopt string `yaml:"adopt,omitempty"`
}
SlackChannel is one channel's binding.
How membership is reconciled is the channel's SlackChannel.Mode, and it is chosen, never inferred from visibility. An `extend` channel (the default) is add-only: the controller adds the people the bindings name and removes nobody, so whatever else is in the channel stays. A `strict` channel is exact: membership is made to match the bindings, removing people only after the directory has vouched for the answer and never past the controller's breaker. Strict is for private channels only: Slack lets only an administrator remove somebody from a public channel, so a bot asked to would be refused at every pass.
func (SlackChannel) Strict ¶
func (c SlackChannel) Strict() bool
Strict reports whether the channel is exact.
type SlackWorkspace ¶
type SlackWorkspace struct {
// Channels are the channels bound in this workspace, keyed by channel
// NAME as Slack spells it (lowercase letters, digits, `-` and `_`, at
// most 80 characters). The controller creates a channel that is
// missing and otherwise adopts the existing one BY NAME;
// [SlackChannel.Adopt] is only a disambiguation.
Channels map[string]SlackChannel `yaml:"channels,omitempty"`
}
SlackWorkspace is one workspace and the channels bound inside it.
type TeamView ¶
TeamView is one GitHub team binding as the console shows it: which organisation, which team, and the internal groups whose holders belong in it.
type ThingSpec ¶
type ThingSpec struct {
// Scopes are the declared scopes this thing exists in. A concrete
// grant naming a scope this thing does not list is refused at load.
Scopes []string `yaml:"scopes,omitempty"`
// Roles is the thing's ladder: role name to [RoleSpec] — the roles it
// directly implies and, optionally, the scopes it is valid on. Empty
// Implies means it implies nothing (a leaf role, most often `viewer`);
// empty Scopes means it is valid on every scope the thing declares,
// which is what every role meant before per-role scoping existed.
Roles map[string]RoleSpec `yaml:"roles,omitempty"`
}
ThingSpec is one declared thing: the scopes it exists in, and its roles, each with the roles it implies and, optionally, the scopes it is itself restricted to.
type Vocabulary ¶
type Vocabulary struct {
// Scopes are the environments and tenant shapes an installation
// recognises: `staging`, `prod`, `devel`, `stage`, or `all` for a thing
// that exists once per installation rather than once per environment.
Scopes map[string]ScopeSpec `yaml:"scopes,omitempty"`
// Things are the subsystems, projects and applications a role is held
// ON: `k8s`, `argocd`, `grafana`. Each names the scopes it exists in
// and the roles its ladder has, with their implies edges.
Things map[string]ThingSpec `yaml:"things,omitempty"`
}
Vocabulary is an installation's OPTIONAL declaration of what a grant name is allowed to mean: which scopes and things exist, which roles each thing has, and which role implies which other one.
It is opt-in on purpose. Every rule below fires only once a `vocabulary` table is present at all: an installation that declares none keeps today's behaviour exactly — any three-segment name is a grant, and nothing checks whether its segments mean anything. Declaring one is a deliberate move from "the schema believes whatever spelling shows up in `groups`" to "the schema can tell a typo, an undeclared role or a stray scope from a real grant, at load."