Documentation
¶
Overview ¶
Package offboard answers one question in one operation: "this person left, are they out?" (Task 20261).
Before this, the answer was no. An enterprise hub let an identity hold five independent credential surfaces, and disabling the account at the IdP severed none of them on its own:
- dashboard sessions, which outlive an IdP disablement until the absolute TTL because a refresh that returns no id_token cannot narrow claims;
- API tokens, which carry an owner binding that nothing consulted on departure, so a departed user's PAT worked until its ExpiresAt;
- glasses links, the same rows with a different Kind, whose own source conceded that an IdP dropping someone from a group "is not noticed until the link expires";
- secret leases held by their running tasks, which keep real credentials materialised inside a sandbox;
- the tasks themselves, still executing on the fleet in their name.
The documented remedy was a hand-written deny binding, which stops the account *acting* but ends nothing it already holds.
Those are what a person can use. What they keep here — personal broker secrets and the grants spending them, grant requests still open, a Claude Code login home on every hub member — is credentials.go (Task 20400): destroyed by default, kept under a legal hold.
Why one package ¶
The CLI and the dashboard both need this, and they need it to mean exactly the same thing. Two implementations of "who counts as this person" is the failure mode that matters: the one that under-matches reports success while leaving a credential live. So resolution, planning and execution live here, and cmd/ and pkg/ui are thin.
Ordering ¶
Severing runs before stopping. Revoking credentials first means a task that notices its lease vanishing and retries cannot re-authenticate; stopping the task first would leave a window where its still-valid session could start another one. The durable credentials go in a single transaction (see statedb.OffboardIdentity) so there is no partially-offboarded state; leases, the stored credentials, tasks and Claude logins cannot join that transaction — they are process memory, the broker, other databases and other hub members' filesystems — and so are applied after it, in that order, each reporting its own failures rather than rolling back a severing that already succeeded. Claude logins come last so a task of theirs is already told to stop before the directory it writes to is removed.
What it will not do ¶
Projects are reported, never deleted. A departing user's projects usually hold the team's work, and an offboarding command that silently removed them would be a data-loss tool wearing a security label. The operator gets the list and reassigns.
Index ¶
- type ClaudeHomeRef
- type ClaudeHomeResult
- type ClaudeHomes
- type ClaudeUnreached
- type Credentials
- type DenyRef
- type Failure
- type GrantRequestRef
- type HoldRef
- type LeaseRef
- type Leases
- type LocalClaude
- type MembershipRef
- type Options
- type PersonalGrantRef
- type PersonalSecretRef
- type Plan
- type ProjectRef
- type Projects
- type Report
- type Secrets
- type SessionRef
- type Sessions
- type Target
- type TaskRef
- type Tasks
- type TokenRef
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type ClaudeHomeRef ¶ added in v0.0.5
type ClaudeHomeRef struct {
// Member names the hub process keeping this copy: a cluster member id, or
// "" for the process running the offboarding.
Member string `json:"member,omitempty"`
// OwnerKey is the spelling of the identity the directory is keyed by.
OwnerKey string `json:"owner_key"`
// Dir is the directory on that member's filesystem.
Dir string `json:"dir"`
// Exists, Credential and Login are what was found there: the directory,
// a non-empty credential file in it, and a `claude auth login` in flight
// for this spelling on that member.
Exists bool `json:"exists"`
Credential bool `json:"credential"`
Login bool `json:"login,omitempty"`
}
ClaudeHomeRef is one copy of a Claude Code login the hub keeps for the identity: the private CLAUDE_CONFIG_DIR one hub process resolves for one spelling of it.
One identity can have several. Each spelling of the person — their email, and "sub:<subject>" from a time the IdP withheld it — keys its own directory, and each hub process resolves the directory under its own config root, so a cluster whose members run under different users or container filesystems keeps one copy per member.
type ClaudeHomeResult ¶ added in v0.0.5
type ClaudeHomeResult struct {
ClaudeHomeRef
// LoginCancelled reports that a login in flight was killed.
LoginCancelled bool `json:"login_cancelled,omitempty"`
// LoggedOut reports that `claude auth logout` ran and succeeded, which
// revokes the session at Anthropic and not only on this disk.
LoggedOut bool `json:"logged_out,omitempty"`
// Logout says why logout did not run, or how it failed. Best-effort: the
// directory is removed either way.
Logout string `json:"logout,omitempty"`
// Removed reports that the directory is gone.
Removed bool `json:"removed,omitempty"`
// Kept reports that a legal hold left the directory in place.
Kept bool `json:"kept,omitempty"`
// Error is what failed when the copy could not be removed.
Error string `json:"error,omitempty"`
}
ClaudeHomeResult is what the run did to one copy.
type ClaudeHomes ¶ added in v0.0.5
type ClaudeHomes interface {
// Inspect finds every copy of these owner keys' logins it can reach, and
// names the hub processes it could not.
Inspect(ownerKeys []string) ([]ClaudeHomeRef, []ClaudeUnreached, error)
// Sever cancels the logins in flight and, unless keep is set, logs each
// copy out and removes it.
Sever(ownerKeys []string, keep bool, actor, reason string) ([]ClaudeHomeResult, []ClaudeUnreached, error)
}
ClaudeHomes is the Claude Code login surface. A run with none skips it and the plan says logins were not checked.
type ClaudeUnreached ¶ added in v0.0.5
ClaudeUnreached is a hub process whose copies the run could not reach: a member that did not answer, or one that stopped and left its tree behind. A copy nobody reached may still hold a live refresh token, so it is reported rather than assumed absent.
type Credentials ¶ added in v0.0.5
type Credentials struct {
Secrets []PersonalSecretRef `json:"secrets"`
Grants []PersonalGrantRef `json:"grants"`
Requests []GrantRequestRef `json:"requests"`
// Claude lists every copy of a Claude login found for the identity,
// on every hub process the run could reach.
Claude []ClaudeHomeRef `json:"claude"`
// ClaudeUnreached lists the hub processes the run could not ask.
ClaudeUnreached []ClaudeUnreached `json:"claude_unreached,omitempty"`
// Keep reports a legal hold: the secrets and the Claude homes listed
// above are kept, everything else is severed.
Keep bool `json:"keep"`
// SecretsError, RequestsError and ClaudeError say why a part of this
// footprint could not be read. A run with one set reports it as a
// failure: what could not be listed could not be destroyed.
SecretsError string `json:"secrets_error,omitempty"`
RequestsError string `json:"requests_error,omitempty"`
ClaudeError string `json:"claude_error,omitempty"`
}
Credentials is the identity's stored footprint: what the run destroys, or under a legal hold keeps.
func (Credentials) ClaudeCopies ¶ added in v0.0.5
func (c Credentials) ClaudeCopies() int
ClaudeCopies counts the copies that hold anything: a directory, or a login in flight.
func (Credentials) Empty ¶ added in v0.0.5
func (c Credentials) Empty() bool
Empty reports whether there is nothing stored to destroy or keep.
type DenyRef ¶
type DenyRef struct {
ID string `json:"id"`
Claim string `json:"claim"`
Value string `json:"value"`
}
DenyRef is a deny binding the run will write.
type Failure ¶
Failure records a surface that could not be severed, so a partial run is legible instead of being reported as a success.
type GrantRequestRef ¶ added in v0.0.5
type GrantRequestRef struct {
ID string `json:"id"`
RequestedBy string `json:"requested_by"`
SecretName string `json:"secret_name,omitempty"`
Kind string `json:"kind,omitempty"`
Subject string `json:"subject"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
GrantRequestRef is one self-service grant request the identity filed that is still waiting for a decision.
type HoldRef ¶ added in v0.0.5
type HoldRef struct {
Identity string `json:"identity"`
Reason string `json:"reason"`
PlacedAt time.Time `json:"placed_at"`
PlacedBy string `json:"placed_by,omitempty"`
}
HoldRef is a legal hold an offboarding found placed on its identity.
type LeaseRef ¶
type LeaseRef struct {
ID string `json:"id"`
ExecutorID string `json:"executor_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
Kinds []string `json:"kinds,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
// GrantIDs are the grants whose material the lease carries, so a lease on
// somebody else's project holding this identity's personal credential can
// be named in the plan (Task 20400).
GrantIDs []string `json:"grant_ids,omitempty"`
}
LeaseRef is one live secret lease held on the departing user's behalf.
type Leases ¶
type Leases interface {
// LiveLeases reports every lease the broker would still honour.
LiveLeases() []LeaseRef
// Release ends one lease and destroys any credentials minted under it.
Release(id string)
}
Leases is the secret-broker surface offboarding needs. A hub with no broker passes nil, and leases are simply not part of the run.
type LocalClaude ¶ added in v0.0.5
type LocalClaude struct {
// Manager holds this process's in-flight logins. Nil — a shell has none.
Manager *claudecodeauth.Manager
// Member labels the copies this process finds: its cluster member id, or
// "" when it is the process running the offboarding.
Member string
// Root is where the homes are. Empty — production — is
// claudecodeauth.HomeRootPath, from this process's own environment.
// Set by tests that run two hub members in one process, where the
// environment is shared and the trees must not be.
Root string
// OperatorShell marks the process as an operator's shell on the hub host —
// `cloop hub user offboard` — rather than a hub acting on a request. The
// host-execution policy governs what a hub runs on a request's behalf; a
// person at a shell runs what they choose, and the logout revokes the
// session at Anthropic, so a shell runs it whatever the policy — the same
// on every host, rather than depending on which directory's config the
// CLI happened to load. Never set by a hub.
OperatorShell bool
// OnLoginCancelled runs after a login in flight was killed, so a cluster
// member can give up its claim on it.
OnLoginCancelled func(ownerKey string)
// Logout runs `claude auth logout` against one directory. Nil is
// claudecodeauth.Logout; tests substitute it, because the real one talks
// to Anthropic.
Logout func(ctx context.Context, configDir string) error
// LogoutTimeout bounds one logout. Zero is defaultLogoutTimeout.
LogoutTimeout time.Duration
}
LocalClaude is the Claude login surface of one process: the homes under its own config directory, and the logins its Manager has in flight. A hub's cluster surface is one of these for itself plus every other member's answer; the CLI's is one of these for its own tree plus what members report over the bus.
func (LocalClaude) Inspect ¶ added in v0.0.5
func (c LocalClaude) Inspect(ownerKeys []string) ([]ClaudeHomeRef, []ClaudeUnreached, error)
Inspect implements ClaudeHomes for this process.
func (LocalClaude) Sever ¶ added in v0.0.5
func (c LocalClaude) Sever(ownerKeys []string, keep bool, actor, reason string) ([]ClaudeHomeResult, []ClaudeUnreached, error)
Sever implements ClaudeHomes for this process.
The order is the one that leaves nothing to come back: the login in flight first, because a `claude auth login` that completes after the directory is gone would write a fresh credential into a new one; then logout, while the credential it revokes still exists; then the directory.
type MembershipRef ¶ added in v0.0.5
type MembershipRef struct {
Path string `json:"path"`
Role string `json:"role"`
// Identity is the spelling the grant was recorded under. Worth keeping: a
// grant found under "sub:…" is the one an email-only sweep would miss.
Identity string `json:"identity"`
}
MembershipRef is one project the identity was admitted to without owning it (Task 20366). The opposite disposition from ProjectRef: an owned project is reported and left alone, because deleting somebody's work is not offboarding, while a membership is removed outright — it is access to a project that belongs to someone else, and ending that access is the point.
type Options ¶
type Options struct {
// DB is the hub's control-plane database. Required.
DB *statedb.DB
// Identity is the email or subject the operator typed. Required, unless
// Target is set.
Identity string
// Target is an identity its caller already resolved (Task 20415): a SCIM
// directory, which knows the person by the claim it matches on and must
// not be widened past an ambiguous match by the expansion an operator's
// input gets. When set, Identity is only the label the trail records.
Target *Target
// Reason is recorded on every audit event. Required: an offboarding with
// no stated cause is not reviewable, and the reviewability is the point.
Reason string
// Actor is who is doing this — an operator's email, or an OS user for CLI
// runs.
Actor string
// Via distinguishes "cli" from "ui" in the trail.
Via string
// DryRun resolves and reports without writing anything.
DryRun bool
// Leases, Tasks and Projects are optional. A nil collaborator means that
// surface is skipped, and the report says so rather than implying it was
// clean.
Leases Leases
Tasks Tasks
Projects Projects
// Sessions is the live session authority. Strongly preferred where one
// exists — see the interface doc.
Sessions Sessions
// Secrets and Claude reach what the identity keeps on the hub (Task
// 20400). Optional like the others: nil means that part is not checked,
// and the plan says so.
Secrets Secrets
Claude ClaudeHomes
// KeepCredentials is a legal hold. Access is severed exactly as without
// it — grants revoked, requests withdrawn, logins in flight cancelled —
// but the personal secrets and the Claude homes are kept as they are, and
// the plan and the trail say what was kept.
KeepCredentials bool
// Now is the clock, swappable in tests.
Now func() time.Time
}
Options configures a run.
type PersonalGrantRef ¶ added in v0.0.5
type PersonalGrantRef struct {
ID string `json:"id"`
SecretID string `json:"secret_id"`
SecretName string `json:"secret_name,omitempty"`
Subject string `json:"subject"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
PersonalGrantRef is one live grant over a personal secret of the identity.
Listed with its subject because a grant to a project somebody else owns is how a colleague's work loses the credential, and an operator approving the run should see that before it happens rather than hear about it afterwards.
type PersonalSecretRef ¶ added in v0.0.5
type PersonalSecretRef struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Owner string `json:"owner,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
PersonalSecretRef is one secret the identity owns personally.
type Plan ¶
type Plan struct {
Target Target `json:"target"`
Sessions []SessionRef `json:"sessions"`
Tokens []TokenRef `json:"tokens"`
Glasses []TokenRef `json:"glasses"`
Denies []DenyRef `json:"denies"`
Leases []LeaseRef `json:"leases"`
Tasks []TaskRef `json:"tasks"`
Projects []ProjectRef `json:"projects"`
// Memberships are projects this identity can reach without owning them
// (Task 20366). Unlike Projects these are removed, in the credential
// transaction.
Memberships []MembershipRef `json:"memberships"`
// Credentials is what the identity keeps on the hub — personal secrets,
// the grants over them, pending requests, Claude logins (Task 20400).
// Destroyed after the transaction, or kept under a legal hold.
Credentials Credentials `json:"credentials"`
// Hold is the legal hold placed on this identity, when there is one
// (Task 20415): it keeps the stored credentials whatever the run was
// asked, so an offboarding nobody ran by hand — the identity provider
// deleting the account — honours it too.
Hold *HoldRef `json:"hold,omitempty"`
// Warnings name things the run cannot be sure about — most importantly
// tokens whose owner binding did not decode, which cannot be proven *not*
// to belong to this person.
Warnings []string `json:"warnings,omitempty"`
}
Plan is the blast radius: everything a run would touch, resolved before anything is written. It is what --dry-run prints.
type ProjectRef ¶
type ProjectRef struct {
Name string `json:"name"`
Path string `json:"path"`
Owner string `json:"owner,omitempty"`
}
ProjectRef is a project owned by the departing user. Reported, never deleted.
type Projects ¶
type Projects interface {
// Owned reports projects whose owner is any of the given owner keys.
Owned(ownerKeys []string) ([]ProjectRef, error)
}
Projects enumerates project ownership.
func RegistryProjects ¶
func RegistryProjects(entries []multiui.ProjectEntry) Projects
RegistryProjects reports ownership from an already-loaded project list.
It takes entries rather than calling multiui.Load itself because Load misses the hub's own WorkDir and anything passed via --projects; the dashboard assembles the full set separately, and an offboarding that swept only the registry file would under-report which projects need reassigning.
type Report ¶
type Report struct {
Plan
DryRun bool `json:"dry_run"`
Actor string `json:"actor,omitempty"`
Reason string `json:"reason,omitempty"`
At time.Time `json:"at"`
SessionsRevoked []string `json:"sessions_revoked,omitempty"`
TokensRevoked []string `json:"tokens_revoked,omitempty"`
GlassesRevoked []string `json:"glasses_revoked,omitempty"`
DeniesWritten []string `json:"denies_written,omitempty"`
LeasesReleased []string `json:"leases_released,omitempty"`
TasksStopped []TaskRef `json:"tasks_stopped,omitempty"`
// MembershipsRevoked are the project memberships actually removed, in
// the same transaction as the credentials (Task 20366).
MembershipsRevoked []MembershipRef `json:"memberships_revoked,omitempty"`
// What became of the stored credentials (Task 20400). Under a legal hold
// SecretsKept replaces SecretsDeleted, and each Claude copy reports Kept.
GrantsRevoked []PersonalGrantRef `json:"grants_revoked,omitempty"`
SecretsDeleted []PersonalSecretRef `json:"secrets_deleted,omitempty"`
SecretsKept []PersonalSecretRef `json:"secrets_kept,omitempty"`
RequestsWithdrawn []GrantRequestRef `json:"requests_withdrawn,omitempty"`
ClaudeResults []ClaudeHomeResult `json:"claude_results,omitempty"`
// Failures is non-empty when a surface could not be severed. The run does
// not abort on one: a lease that will not release is not a reason to leave
// the sessions live. The caller decides what a partial run means, and both
// front ends report it as an error with the detail attached.
Failures []Failure `json:"failures,omitempty"`
}
Report is the outcome of a run. A dry run returns the Plan with DryRun set and every "-ed" field empty.
type Secrets ¶ added in v0.0.5
type Secrets interface {
// Owned reports the personal secrets owned by any of these owner keys,
// and every live grant over them.
Owned(ownerKeys []string) ([]PersonalSecretRef, []PersonalGrantRef, error)
// Pending reports the requests still awaiting a decision that were filed
// by any of these identities.
Pending(requesters []string) ([]GrantRequestRef, error)
// RevokeGrant revokes one grant.
RevokeGrant(id, actor, reason string) error
// DeleteSecret destroys one secret: its sealed material, and every grant
// still pointing at it.
DeleteSecret(id, actor, reason string) error
// WithdrawRequest withdraws one pending request on behalf of its departed
// requester, reporting false when it had already been decided.
WithdrawRequest(id, actor, reason string) (bool, error)
}
Secrets is the secret-store surface: the identity's personal secrets, the grants spending them, and the requests it filed. A run with none skips these and the plan says they were not checked.
func BrokerSecrets ¶ added in v0.0.5
func BrokerSecrets(b *secretbroker.Broker) Secrets
BrokerSecrets adapts a secret broker.
It reads with the broker's unscoped listings — offboarding is one of the callers ListSecrets stays unscoped for — and writes only through the broker, so every revocation, deletion and withdrawal is audited by the code that audits them everywhere else, and a deleted secret leaves the tombstone a later refusal names it from.
func StoreSecrets ¶ added in v0.0.5
StoreSecrets builds the secret surface over a control-plane database.
The broker it builds holds no key (secretbroker.WithoutKey). Destroying a credential removes rows and never reads one, so an offboarding works from a shell that does not carry CLOOP_SECRET_KEY — the emergency the CLI exists for — and the hub uses the same construction, so both front ends sever the same rows the same way. Its audit rows go to the control plane, where every secret action is homed.
type SessionRef ¶
type SessionRef struct {
ID string `json:"id"`
Subject string `json:"subject,omitempty"`
Email string `json:"email,omitempty"`
IP string `json:"ip,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
// Kind is browser or cli: a terminal signed in with `cloop login` holds a
// session like a browser does, and is severed with it (Task 20412).
Kind string `json:"kind,omitempty"`
// Username and OID are the preferred_username and oid claims the session
// signed in with (Task 20415).
Username string `json:"username,omitempty"`
OID string `json:"oid,omitempty"`
}
SessionRef is one dashboard session that will be, or was, ended.
type Sessions ¶
type Sessions interface {
// List reports every live session.
List() ([]SessionRef, error)
// Revoke ends one session and drops it from any in-process cache.
// It reports whether a session was actually ended.
Revoke(id, actor, reason string) (bool, error)
}
Sessions is the running hub's session authority — its authenticator.
Supplying it matters more than it looks. A hub whose session store is not durable (no sealing key, or no control-plane database) keeps sessions in process memory, where the control-plane table this package otherwise reads is simply empty. An offboarding driven off that table would report "0 sessions" and leave the person signed in — the worst possible answer to "are they out?". Going through the authenticator is also what drops the session from its 30-second read cache, which deleting the row underneath it does not.
A caller with no running hub (the CLI, on a host whose hub may be down) passes nil and the control-plane table is used instead, which is correct there: there is no process holding memory sessions to ask.
type Target ¶
type Target struct {
// Input is what the operator typed, preserved for the audit trail.
Input string `json:"input"`
// Key is the canonical owner key: lowercased email, or "sub:"+Subject.
// This is what project ownership rows are recorded under.
Key string `json:"key"`
// Subjects and Emails are every identifier found to belong to this person.
// Emails are lowercased; subjects are opaque and compared exactly.
//
// Always serialised, even when empty: a front end rendering "resolved to"
// needs to distinguish "no subject" from a field that was omitted, and
// omitempty would make an identity this hub has never seen indistinguishable
// from a decoding bug.
Subjects []string `json:"subjects"`
Emails []string `json:"emails"`
// Usernames and OIDs are preferred_username and Entra oid values that
// address this person too (Task 20415). Only a SCIM directory matching on
// one of those claims puts them here: they are what the identity provider
// deactivated, and a deny binding on them refuses a sign-in that carries
// neither a known subject nor a known email. Lowercased.
Usernames []string `json:"usernames,omitempty"`
OIDs []string `json:"oids,omitempty"`
}
Target is the resolved identity: every identifier that turned out to address the same person.
Resolution matters because the operator types one thing — usually an email — and the surfaces are keyed inconsistently. Sessions carry a subject and an email; a token's owner may carry only a subject. Matching literally on what was typed would leave the subject-keyed token behind, which is precisely the credential an operator would never think to look for.
func ResolveTarget ¶ added in v0.0.5
ResolveTarget expands seeds — emails, and subjects spelt "sub:<subject>" — into every identifier that addresses the same person, as an operator's input is expanded (Task 20415). A SCIM directory calls it with the identifiers the identity provider vouched for, and checks the result for an ambiguity before acting on it.
type TaskRef ¶
type TaskRef struct {
ProjectPath string `json:"project_path"`
ProjectName string `json:"project_name,omitempty"`
ID int `json:"id"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
// Attempt names the execution observed, so a kill filed here cannot land on
// a later attempt of the same task (Task 20203). Not serialised: it is an
// internal fencing token, not something an operator reads.
Attempt string `json:"-"`
}
TaskRef is one in-flight task running in a project this identity owns.
type Tasks ¶
type Tasks interface {
// Running reports in-flight tasks for one project path.
Running(projectPath string) ([]TaskRef, error)
// Stop requests that the named execution be killed. The ref carries the
// attempt token, so the request cannot land on a later attempt.
Stop(t TaskRef, actor, reason string) error
}
Tasks is the per-project execution surface. A hub that cannot reach project databases passes nil.
func LocalTasks ¶
func LocalTasks() Tasks
LocalTasks reads and aborts tasks through each project's own state database, which is where a project's plan lives — the hub's control plane does not hold other projects' tasks.
type TokenRef ¶
type TokenRef struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Prefix string `json:"prefix,omitempty"`
Kind string `json:"kind,omitempty"`
Owner string `json:"owner,omitempty"`
Roles []string `json:"roles,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
TokenRef is one API token or glasses link.