policy

package
v1.76.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 21 Imported by: 0

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/sluis/policy.md.

Index

Constants

View Source
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"

	// ThingSluis is the sluis spelling of [ThingSelf]. The hub reads both
	// during the dual-name window and the old one goes in v1.76; a policy
	// moves its groups (`all:sluis:operator`) once every estate runs a
	// release that reads the new name.
	ThingSluis = "sluis"

	// 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/concepts/sluis/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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
const (
	// SlackModeExtend only adds. It is the default.
	SlackModeExtend = "extend"
	// SlackModeStrict adds and removes.
	SlackModeStrict = "strict"
)

The channel modes.

View Source
const LifetimeDefault = "default"

LifetimeDefault is the key under which the fallback lifetime lives.

View Source
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

View Source
var (
	GroupOperators = ScopedGroup(ScopeAll, RoleOperator)
	GroupViewers   = ScopedGroup(ScopeAll, RoleViewer)
	// GroupRosterControllers holds the GitHub and Slack roster controllers'
	// own identities, and nobody else's. It grants no role: the console
	// reads it to serve those two, unrecorded, the one call (Explain) that
	// is otherwise an operator's. The policy's `service_account` or `aws`
	// matchers for the controllers put them in it.
	GroupRosterControllers = ScopedGroupOf(ThingSluis, ScopeAll, "roster-controller")
	// 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.

View Source
var ErrUnknownGroup = errors.New("policy: not a declared group")

ErrUnknownGroup is returned for a group name the policy does not have.

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 CanonicalResourceID added in v1.74.0

func CanonicalResourceID(id string) string

CanonicalResourceID folds the scheme and the host of a resource indicator to lower case, which RFC 3986 (6.2.2.1) and RFC 8707 make insignificant, and leaves everything else as it is.

The path, the query and a trailing slash are NOT touched: a resource is still matched exactly beyond the authority, so `https://mcp.example` and `https://mcp.example/` remain different resources.

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

func IsAccessDocument(data []byte) bool

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 IsOperators added in v1.75.0

func IsOperators(name string) bool

IsOperators reports whether name is the installation-wide operators group under either spelling of the thing.

func IsViewers added in v1.75.0

func IsViewers(name string) bool

IsViewers reports whether name is the installation-wide viewers group under either spelling of the thing.

func Plain

func Plain(v any) any

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

func RewriteGroupsDelimiter(name, delimiter string) string

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/sluis/policy.md#groups-delimiter-per-audience-opkssh-interop.

func ScopedGroup

func ScopedGroup(workspace, role string) string

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 ScopedGroupOf added in v1.75.0

func ScopedGroupOf(thing, workspace, role string) string

ScopedGroupOf is ScopedGroup with the thing spelled by the caller.

func SplitGroup

func SplitGroup(name string) (scope, thing, role string, ok bool)

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

func SplitScopedGroup(name string) (workspace, role string, mine bool)

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

func ValidAWSAccount(s string) bool

ValidAWSAccount reports whether s is an AWS account id: exactly twelve ASCII digits.

func ValidSlackChannelID

func ValidSlackChannelID(id string) bool

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

func ValidWorkspaceID(s string) bool

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.

func (AWSRole) Subject

func (r AWSRole) Subject() string

Subject is how a role is spelled as a token's `sub`: `aws:<account>:role/<path><name>`, scope first like every other subject. The session is never part of it.

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: [...]

func (AccessDocument) Policy

func (d AccessDocument) Policy() (Policy, error)

Policy reshapes the document into the layer Parse would have read had it been written out by hand.

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

type AccessScope struct {
	Name      string `yaml:"name"`
	Sensitive bool   `yaml:"sensitive,omitempty"`
}

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/sluis/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) Admits

func (c Client) Admits(r Result) bool

Admits reports whether a result holds any group the client requires.

func (Client) Agent added in v1.69.0

func (c Client) Agent() bool

Agent reports whether this client's chains are agent-class.

func (Client) Cap

func (c Client) Cap(lifetime time.Duration) time.Duration

Cap applies a client's ttl_cap to a lifetime.

func (Client) SecretGenerated added in v1.66.0

func (c Client) SecretGenerated() bool

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

func (c Client) SecretName() string

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

func (c Client) Title(id string) string

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

type ClientView struct {
	ID string
	Client
}

ClientView is one declared client as the console shows it.

type Duration

type Duration time.Duration

Duration is a time.Duration that reads Go duration strings from YAML, because a policy file is read by people.

func (Duration) Duration

func (d Duration) Duration() time.Duration

Duration returns the value as a time.Duration.

func (Duration) MarshalYAML

func (d Duration) MarshalYAML() (any, error)

MarshalYAML implements yaml.Marshaler.

func (*Duration) UnmarshalYAML

func (d *Duration) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML implements yaml.Unmarshaler.

type Fragment

type Fragment map[string]any

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

func (o GitHubOrg) IgnoredAddresses() map[string]bool

IgnoredAddresses are the ignored work addresses, lowercased.

func (GitHubOrg) IgnoredLogins

func (o GitHubOrg) IgnoredLogins() map[string]bool

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.

func (Matcher) Describe

func (m Matcher) Describe() string

Describe renders a matcher the way the console shows it.

func (Matcher) Kind

func (m Matcher) Kind() string

Kind names what a matcher admits: ci, workload or sign-in.

func (Matcher) Rule

func (m Matcher) Rule() string

Rule is the pattern alone, without the kind: what a reviewer compares against the thing that presented itself.

type MatcherView

type MatcherView struct {
	Kind string
	Rule string
}

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/sluis/policy.md#vocabulary and
	// docs/reference/sluis/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

func LoadDeclared(path string) (Policy, error)

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

func Parse(data []byte) (Policy, error)

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

func ParseAccess(data []byte) (Policy, error)

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

func ParseLayer(data []byte) (Policy, error)

ParseLayer reads one declared layer: a policy file, or an access document, which is reshaped into one (ParseAccess).

func (Policy) Digest

func (p Policy) Digest() (string, error)

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

func (p Policy) Evaluate(in Input) Result

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:

  1. 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.
  2. 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

func (p *Policy) Merge(other Policy, from string) error

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

func (p Policy) PeopleByAddress() map[string]string

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

func (p Policy) ScopeGroups(audience string, held []string) (kept, dropped []string)

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/sluis/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

func (p Policy) Unconsumed(catalogueGroups ...string) []string

Unconsumed returns the sorted names of internal groups that are declared in Policy.Groups but referenced by none of:

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

func (p Policy) UnconventionalGroups() []string

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.

func (Policy) Validate

func (p Policy) Validate() error

Validate checks one layer on its own: shapes, references within it, and the rule that no two fragments may set one scalar differently.

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

func (r Resource) AbsoluteFor(global time.Duration) time.Duration

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) Admits

func (r Resource) Admits(result Result) bool

Admits reports whether a caller holds a group this resource requires.

func (Resource) CheckAbsoluteCap

func (r Resource) CheckAbsoluteCap(id string, global time.Duration) error

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

func (r Resource) LengthensAbsolute(global time.Duration) bool

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.

func (Resource) Title

func (r Resource) Title(id string) string

Title is what a person is shown this resource as, given its id.

type ResourceView

type ResourceView struct {
	ID string
	Resource
}

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.

func (Result) Has

func (r Result) Has(group string) bool

Has reports whether an internal group is held.

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

func (r RoleSpec) MarshalYAML() (any, error)

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

func (r *RoleSpec) UnmarshalYAML(node *yaml.Node) error

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 NewSet

func NewSet(declared Policy) (*Set, error)

NewSet validates a declared layer and returns it as the policy in force.

func (*Set) AgentAbsolute added in v1.69.0

func (s *Set) AgentAbsolute(class time.Duration, touched []string) time.Duration

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

func (s *Set) BrowserFacingAgents() []string

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) Client

func (s *Set) Client(id string) (Client, bool)

Client returns a declared client.

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

func (s *Set) Declared() Policy

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

func (s *Set) Digest() string

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

func (s *Set) EffectiveAbsolute(global time.Duration, touched []string) time.Duration

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) Evaluate

func (s *Set) Evaluate(in Input) Result

Evaluate resolves a proof against the policy in force.

func (*Set) GitHubOrgs

func (s *Set) GitHubOrgs() []OrgView

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

func (s *Set) GitHubTeams() []TeamView

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) Groups

func (s *Set) Groups() []GroupView

Groups returns every internal group as the console shows it.

func (*Set) HasGroup

func (s *Set) HasGroup(name string) bool

HasGroup reports whether an internal group is declared.

func (*Set) Resource

func (s *Set) Resource(id string) (Resource, bool)

Resource returns a declared resource.

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

func (s *Set) ScopeGroups(audience string, held []string) (kept, dropped []string)

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

func (s *Set) SlackWorkspaceDeclared(key string) bool

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

func (s *Set) SlackWorkspaceKeys() []string

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

type TeamView struct {
	Org         string
	Team        string
	Members     []string
	Maintainers []string
}

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."

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL