provision

package
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package provision is what SCIM provisioning means to the hub (Task 20415): which hub identity a provisioned user is, what deactivating one severs, what reactivating one restores, what deleting one destroys, and what a provisioned group grants.

pkg/scim speaks the protocol and decides nothing. This package decides, and leaves the HTTP surface — authentication, rate limiting, the routes — to pkg/ui, and storage to pkg/statedb.

The gap it closes

"Disabling an account at the identity provider does not, on its own, sever anything this hub has already issued" was a sentence in the runbook, and true: sessions ran to their absolute TTL, API tokens to their ExpiresAt, glasses links for up to 30 days, and leases, running tasks and stored credentials until an administrator ran `cloop hub user offboard`. With an identity provider provisioning the hub over SCIM, the provider's own deactivation is the trigger:

  • active:false suspends the identity. In one transaction the hub writes a deny binding for every identifier it resolved, deletes the sessions, revokes the API tokens and glasses links, and suspends the grants over the person's personal secrets; then it releases their leases and stops their running tasks. Memberships and stored credentials are kept.
  • active:true lifts the suspension: the deny bindings it wrote and the grants it suspended, and nothing else. What it destroyed stays destroyed.
  • DELETE, or ui.oidc.scim.offboard_after_days of inactivity, runs the full offboarding (pkg/offboard) — under a legal hold when one is placed.
  • A provisioned group is a claim source a role binding can name (claim: scim_group), read at every authorization decision.

Who a provisioned user is

ui.oidc.scim.match_claim says which claim of a signed-in identity a provisioned user is matched against: email (the default), preferred_username, oid or sub. A match that addresses more than one hub identity is never resolved by guessing — see targetFor.

Index

Constants

View Source
const CredentialPrefix = "cloop_scim_"

CredentialPrefix starts every SCIM credential. Exported so a scanner and the redaction registry have one constant to match.

View Source
const DefaultCheckEvery = 2 * time.Second

DefaultCheckEvery is how often a cache asks the database whether the directory changed, when nothing told it so.

View Source
const DefaultRatePerMinute = 600

DefaultRatePerMinute is a SCIM credential's request budget when ui.oidc.scim.rate_limit_per_minute is unset: enough for an identity provider's initial cycle over a directory of thousands, which it paces on 429s anyway, and a ceiling on what a stolen credential can do in a minute.

View Source
const MaxGrace = 7 * 24 * time.Hour

MaxGrace bounds how long a rotated-out secret may stay valid.

Variables

View Source
var (
	ErrNoCredential  = errors.New("provision: not a SCIM credential")
	ErrBadCredential = errors.New("provision: unknown or wrong SCIM credential")
	ErrRevoked       = errors.New("provision: the SCIM credential was revoked")
)

Credential errors. Every one of them is the same 401 to a client.

View Source
var ErrInvalid = errors.New("provision: invalid")

ErrInvalid wraps a request the hub refuses for a reason of its own, not the protocol's: a credential name that is malformed or taken, a hold with no reason.

MatchClaims lists the valid match claims, the default first.

Functions

func FindCredential

func FindCredential(db *statedb.DB, ref string) (statedb.SCIMCredentialRow, error)

FindCredential resolves a credential by id, or by the name of a live one.

func HoldIdentity

func HoldIdentity(raw string) (string, error)

HoldIdentity reads the identity a hold is placed on: an email, lowercased, or a subject, as sub:<subject>. A bare value with no "@" is a subject.

func ParseCredential

func ParseCredential(raw string) (id, secret string, ok bool)

ParseCredential splits a credential into its id and secret without touching storage; ok is false for anything not shaped like one.

func PlaceHold

func PlaceHold(db *statedb.DB, identity, holdReason string, ac AdminContext, now time.Time) (statedb.LegalHoldRow, error)

PlaceHold places a legal hold on identity, or restates the reason of one already placed.

func ReleaseHold

func ReleaseHold(db *statedb.DB, identity string, ac AdminContext, now time.Time) (statedb.LegalHoldRow, bool, error)

ReleaseHold releases the hold on identity, reporting whether one was placed.

func RevokeCredential

func RevokeCredential(db *statedb.DB, ref string, ac AdminContext, now time.Time) (statedb.SCIMCredentialRow, bool, error)

RevokeCredential revokes a credential, both its secrets at once. A credential already revoked is reported, not an error.

func SubjectValue

func SubjectValue(claim MatchClaim, s *authz.Subject) string

SubjectValue is the value a signed-in subject is matched by under claim.

func UserValue

func UserValue(claim MatchClaim, row statedb.SCIMUserRow) string

UserValue is the value a provisioned user is matched by under claim: what its identity must carry in the claim to be this user. "" means the user cannot be matched.

func ValidCredentialName

func ValidCredentialName(name string) error

ValidCredentialName reports whether name may name a credential: it becomes part of the audit actor, scim:<name>, so it is short and plain.

Types

type Actor

type Actor struct {
	CredentialID string
	Name         string
}

Actor is the SCIM credential a request authenticated with.

func (Actor) Label

func (a Actor) Label() string

Label is how the audit trail names the actor: scim:<credential name>.

type AdminContext

type AdminContext struct {
	Actor  string
	Reason string
	// Via is cli or ui; OSUser the shell user for a CLI change.
	Via    string
	OSUser string
}

AdminContext is who administers a credential or a hold, for the trail.

type Config

type Config struct {
	// MatchClaim says which claim a provisioned user is matched against.
	MatchClaim MatchClaim
	// OffboardAfter is how long a user stays suspended before the full
	// offboarding runs. Zero never runs it: only a DELETE does.
	OffboardAfter time.Duration
}

Config is a hub's provisioning policy, ui.oidc.scim.

type Directory

type Directory struct {
	// contains filtered or unexported fields
}

Directory is a cached, indexed copy of the provisioned directory.

func NewDirectory

func NewDirectory(db *statedb.DB, claim MatchClaim, opts ...DirectoryOption) (*Directory, error)

NewDirectory reads the directory once, eagerly: a hub that cannot read it at startup must not come up granting groups from nothing.

func (*Directory) Claim

func (d *Directory) Claim() MatchClaim

Claim reports the match claim the directory indexes by.

func (*Directory) DirectoryGroups

func (d *Directory) DirectoryGroups(s *authz.Subject) []string

DirectoryGroups implements authz.DirectorySource.

func (*Directory) Invalidate

func (d *Directory) Invalidate()

Invalidate makes the next read check the version, and reload on a change.

func (*Directory) Stats

func (d *Directory) Stats() DirectoryStats

Stats reports the directory's summary.

func (*Directory) UserFor

func (d *Directory) UserFor(s *authz.Subject) (id string, active, unique bool)

UserFor reports the provisioned user a subject matches, and whether the match is unique and active.

type DirectoryOption

type DirectoryOption func(*Directory)

DirectoryOption configures a Directory.

func WithCheckEvery

func WithCheckEvery(d time.Duration) DirectoryOption

WithCheckEvery sets how often the version is checked.

func WithDirectoryClock

func WithDirectoryClock(now func() time.Time) DirectoryOption

WithDirectoryClock replaces the clock, for tests.

func WithDirectoryErrors

func WithDirectoryErrors(fn func(error)) DirectoryOption

WithDirectoryErrors installs a sink for failed refreshes: the cache keeps serving its last good copy, and the hub logs why.

type DirectoryStats

type DirectoryStats struct {
	Version     int64 `json:"version"`
	Users       int   `json:"users"`
	Active      int   `json:"active"`
	Inactive    int   `json:"inactive"`
	Groups      int   `json:"groups"`
	Memberships int   `json:"memberships"`
	// Unmatchable users carry no value for the match claim, and so are no
	// hub identity at all; Ambiguous users share their value with another.
	Unmatchable int `json:"unmatchable"`
	Ambiguous   int `json:"ambiguous"`
}

DirectoryStats summarise the directory, for Settings and the doctor.

type Hub

type Hub interface {
	// Collaborators returns what an offboarding reaches in this hub process
	// — its live sessions, leases, tasks, project registry, secret store and
	// Claude homes — for the control plane db. DB, Identity, Target, Reason,
	// Actor and Via are the caller's to set.
	Collaborators(db *statedb.DB) offboard.Options
	// Severed is told, after a change committed, what it ended or restored
	// behind the hub's caches, so the hub — and its other members — stop
	// honouring it now rather than at their next refresh.
	Severed(Severance)
	// DirectoryChanged is told after every committed directory change.
	DirectoryChanged()
}

Hub is the running hub's side of a provisioning change: what lives in its process rather than in the database.

type MatchClaim

type MatchClaim string

MatchClaim is the claim a provisioned user is matched against.

const (
	// MatchEmail matches the user's primary email against the email claim
	// (or an address-shaped preferred_username, as sign-in reads one).
	MatchEmail MatchClaim = "email"
	// MatchUsername matches userName against preferred_username.
	MatchUsername MatchClaim = "preferred_username"
	// MatchOID matches externalId against Entra ID's oid claim — what
	// Entra provisions as externalId when objectId is mapped to it.
	MatchOID MatchClaim = "oid"
	// MatchSub matches externalId against the subject — what Okta provisions
	// as externalId.
	MatchSub MatchClaim = "sub"
)

func ParseMatchClaim

func ParseMatchClaim(s string) (MatchClaim, error)

ParseMatchClaim reads a configured match claim. Empty is MatchEmail.

type Minted

type Minted struct {
	Row       statedb.SCIMCredentialRow
	Plaintext string
}

Minted is a credential and, once, its plaintext.

func MintCredential

func MintCredential(db *statedb.DB, name string, ac AdminContext, now time.Time) (Minted, error)

MintCredential creates a credential. The plaintext is returned here and never again.

func RotateCredential

func RotateCredential(db *statedb.DB, ref string, grace time.Duration, ac AdminContext, now time.Time) (Minted, error)

RotateCredential replaces a live credential's secret. The secret it replaces stays valid for grace — zero ends it now.

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter is a token bucket per credential.

func NewRateLimiter

func NewRateLimiter(perMinute int) *RateLimiter

NewRateLimiter allows perMinute requests a minute per credential, in bursts of up to a sixth of that (at least ten). A non-positive perMinute is DefaultRatePerMinute.

func (*RateLimiter) Allow

func (l *RateLimiter) Allow(key string) (bool, time.Duration)

Allow takes one request from key's bucket. When the bucket is empty it reports false and how long until a request would be admitted.

func (*RateLimiter) PerMinute

func (l *RateLimiter) PerMinute() int

PerMinute reports the configured rate.

func (*RateLimiter) SetClock

func (l *RateLimiter) SetClock(now func() time.Time)

SetClock replaces the clock, for tests.

type Result

type Result struct {
	// Status is the HTTP status.
	Status int
	// Resource is the representation returned, nil for 204.
	Resource map[string]any
	// Location is the resource's URL, for a 201.
	Location string
	// Version is the resource's version, for the ETag header; 0 for none.
	Version int64
}

Result is one operation's response.

type Service

type Service struct {
	DB        *statedb.DB
	Config    Config
	Directory *Directory
	Hub       Hub
	// Now is the clock; nil is time.Now.
	Now func() time.Time
	// contains filtered or unexported fields
}

Service applies SCIM requests to the directory and the hub.

func (*Service) CreateGroup

func (s *Service) CreateGroup(ctx context.Context, a Actor, body []byte, base string) (Result, error)

CreateGroup serves POST /Groups.

func (*Service) CreateUser

func (s *Service) CreateUser(ctx context.Context, a Actor, body []byte, base string) (Result, error)

CreateUser serves POST /Users.

func (*Service) DeleteGroup

func (s *Service) DeleteGroup(ctx context.Context, a Actor, id, ifMatch string) (Result, error)

DeleteGroup serves DELETE /Groups/{id}.

func (*Service) DeleteUser

func (s *Service) DeleteUser(ctx context.Context, a Actor, id, ifMatch string) (Result, error)

DeleteUser serves DELETE /Users/{id}: the full offboarding, honouring a legal hold, and then the resource is gone.

func (*Service) GetGroup

func (s *Service) GetGroup(id string, q url.Values, base string) (Result, error)

GetGroup serves GET /Groups/{id}.

func (*Service) GetUser

func (s *Service) GetUser(id string, q url.Values, base string) (Result, error)

GetUser serves GET /Users/{id}.

func (*Service) ListGroups

func (s *Service) ListGroups(q url.Values, base string) (scim.ListResponse, error)

ListGroups serves GET /Groups.

func (*Service) ListUsers

func (s *Service) ListUsers(q url.Values, base string) (scim.ListResponse, error)

ListUsers serves GET /Users.

func (*Service) OffboardExpired

func (s *Service) OffboardExpired(ctx context.Context) (int, error)

OffboardExpired runs the full offboarding for every user inactive for longer than Config.OffboardAfter, and reports how many it offboarded.

func (*Service) PatchGroup

func (s *Service) PatchGroup(ctx context.Context, a Actor, id string, body []byte, ifMatch, base string) (Result, error)

PatchGroup serves PATCH /Groups/{id}: renames, and member additions and removals in every dialect pkg/scim reads. It answers 204, as RFC 7644 allows, rather than a group of thousands of members nobody asked to see.

func (*Service) PatchUser

func (s *Service) PatchUser(ctx context.Context, a Actor, id string, body []byte, ifMatch, base string) (Result, error)

PatchUser serves PATCH /Users/{id}: Entra ID's deactivation and attribute updates, and Okta's deactivation.

func (*Service) ReplaceGroup

func (s *Service) ReplaceGroup(ctx context.Context, a Actor, id string, body []byte, ifMatch, base string) (Result, error)

ReplaceGroup serves PUT /Groups/{id}. A body without members leaves the membership as it is: the absence of the attribute is not "no members".

func (*Service) ReplaceUser

func (s *Service) ReplaceUser(ctx context.Context, a Actor, id string, body []byte, ifMatch, base string) (Result, error)

ReplaceUser serves PUT /Users/{id}: Okta's profile update, and its deactivation on older integrations.

type Severance

type Severance struct {
	Actor string
	// RoleBindings: deny bindings were written or lifted.
	RoleBindings bool
	// Tokens are API tokens and glasses links revoked in the transaction.
	Tokens []string
	// Sessions are session rows deleted in the transaction — only on a hub
	// with no running authenticator, which otherwise ends them itself.
	Sessions []string
	// GrantsSuspended and GrantsResumed are grants over the person's
	// personal secrets.
	GrantsSuspended []string
	GrantsResumed   []string
	// Offboard is the report of a full offboarding, when one ran.
	Offboard *offboard.Report
}

Severance is what one change ended or restored.

type Verified

type Verified struct {
	Actor Actor
	// Previous reports that the secret presented is the one a rotation
	// replaced, still inside its grace period.
	Previous bool
}

Verified is a credential that authenticated a request.

type Verifier

type Verifier struct {
	DB  *statedb.DB
	Now func() time.Time
	// contains filtered or unexported fields
}

Verifier checks presented credentials, recording uses at most once a minute per credential.

func (*Verifier) Verify

func (v *Verifier) Verify(raw string) (Verified, error)

Verify authenticates a presented credential.

The secret is compared before revocation is consulted, so a caller without the secret cannot learn that an id exists, or that it was revoked.

Jump to

Keyboard shortcuts

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