identity

package
v0.2.39 Latest Latest
Warning

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

Go to latest
Published: Nov 2, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package identity is the hub's scoped-identity service: the one place a runtime credential for a tenant object is created.

Before this package there were three minters (cross-provider-simplification §"One identity service"): the hub workload exchange, the agents provider's per-agent ServiceAccounts, and the edges provider's per-edge ones. The last two minted NON-EXPIRING tokens from inside a provider, over API groups the provider did not own, with nothing collecting them when the agent or edge went away. That is finding M7, and it cannot be closed provider-side.

What this package guarantees:

  • Every identity has an OWNER: a real object in a tenant workspace. When the owner is gone the identity is collected (reconciler.go).
  • Every identity's rules passed a POLICY (policy.go). A provider cannot mint itself, or anybody else, a capability over another provider's group beyond named reads and declared verbs.
  • Every token is TokenRequest-minted and TTL'd, at most 24h, and is never persisted — not in the record, not in a Secret.
  • Every identity is RECORDED as a ScopedIdentity in root:railgrid:system:tenants, which no tenant or provider can read.

Index

Constants

View Source
const (
	// DefaultWorkloadTTL is the lifetime of a pod-attested workload token. It
	// is short because the runtime can always come back through the exchange.
	DefaultWorkloadTTL = 10 * time.Minute

	// DefaultProviderTTL is the lifetime of a provider-asserted token. It is
	// longer because the holder is a long-running agent process that refreshes
	// on a timer, and shorter than any credential these consumers hold today,
	// all of which never expire at all.
	DefaultProviderTTL = time.Hour
)
View Source
const (
	CodeStaleOwner      = "stale_owner"
	CodeVersionConflict = "version_conflict"
)
View Source
const (
	AnnotationRecord    = "railgrid.ai/scoped-identity"
	AnnotationProvider  = "railgrid.ai/scoped-identity-provider"
	AnnotationOwnerKind = "railgrid.ai/scoped-identity-owner-kind"
	AnnotationOwnerName = "railgrid.ai/scoped-identity-owner-name"
	AnnotationOwnerUID  = "railgrid.ai/scoped-identity-owner-uid"
)

Annotations stamped on a provider-asserted identity's ServiceAccount. They are an audit marker: they say which record and which owner the account belongs to, so an operator reading a tenant workspace can tell what a railgrid-si-* account is for without hunting for the hash preimage.

View Source
const (
	CodeEmptyRule        = "empty_rule"
	CodeMultiGroup       = "multi_group_rule"
	CodeCoreGroup        = "core_group_forbidden"
	CodeWildcard         = "wildcard_forbidden"
	CodeUnnamedForeign   = "unnamed_foreign_rule"
	CodeForeignWrite     = "foreign_write_forbidden"
	CodeForeignList      = "foreign_list_not_name_scoped"
	CodeUnknownProvider  = "unknown_group"
	CodeUnboundProvider  = "provider_not_bound"
	CodeUndeclaredVerb   = "undeclared_verb"
	CodeNonResourceRule  = "non_resource_rule"
	CodeRuleLimit        = "too_many_rules"
	CodeUnknownRequester = "unknown_requester"
	// CodeUnknownOwner means the owner object the caller named does not exist
	// in the tenant workspace (or exists with a different UID).
	CodeUnknownOwner = "unknown_owner"
	// CodeInvalidRequest means the request was malformed before any policy ran.
	CodeInvalidRequest = "invalid_request"
	// CodePlatformNotAllowed means the rule named a platform API group but
	// asked for something outside that group's closed allowlist.
	CodePlatformNotAllowed = "platform_rule_not_allowed"
)

Refusal codes. They are part of the API: the SDK client surfaces them.

View Source
const (
	// DefaultSweepInterval is how often the reconciler walks the records. It
	// is well under the shortest TTL so a record is never missed by more than
	// one interval.
	DefaultSweepInterval = 2 * time.Minute
)

Variables

View Source
var ErrNotFound = errors.New("scoped identity not found")

ErrNotFound is returned when no identity exists for an owner.

View Source
var ErrWorkloadScopeRevoked = errors.New("workload identity scope revoked")

ErrWorkloadScopeRevoked marks a recorded Project environment identity whose current Project state can no longer authorize the recorded environment and runtime. A sweep narrows the recorded identity to no permissions, preserving the record as a durable retry point. Resolver transport and client errors must not wrap this sentinel, so transient reads leave the identity intact.

Functions

func OwnerHash

func OwnerHash(clusterID string, owner Owner) string

OwnerHash is the label value records are listed and released by. It keys on the owner tuple, so releasing an owner releases every account minted for it.

func RecordName

func RecordName(clusterID, serviceAccount string) string

RecordName is the deterministic ScopedIdentity object name: the tenant cluster plus the account name. It keys on the ACCOUNT rather than the owner because one owner can have more than one account — an App Studio Project has one workload identity per (environment, instance) — while every account has exactly one record. Both modes' account names are themselves hashes of their full identity tuple, so this is unique in both.

func ServiceAccountName

func ServiceAccountName(owner Owner) string

ServiceAccountName is the deterministic account name for an owner tuple: a hash of (provider, kind, owner name, owner UID), per the §10 design. It is hash-only on purpose — owner names can carry information that should not be reflected in cluster-wide RBAC object names — and it includes the UID so a deleted-and-recreated owner never inherits its predecessor's identity.

func Shape

Shape converts a record into the minter's shape. Everything the minter writes comes from here, and everything here comes from the record, so the record really is the full description of what exists in the tenant workspace — which is what lets garbage collection delete exactly the right objects without knowing which attestation mode created them.

Types

type APIBindingChecker

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

APIBindingChecker answers whether a provider's APIExport is bound in a tenant workspace, by reading the workspace's APIBindings as the hub.

func NewAPIBindingChecker

func NewAPIBindingChecker(kcpConfig *rest.Config, registry *providers.Registry) (*APIBindingChecker, error)

NewAPIBindingChecker builds a checker over the hub's kcp config.

func NewBindingCheckerForClient

func NewBindingCheckerForClient(client dynamic.Interface, registry *providers.Registry) *APIBindingChecker

NewBindingCheckerForClient is a focused test seam.

func (*APIBindingChecker) IsBound

func (c *APIBindingChecker) IsBound(clusterID, provider string) (bool, error)

IsBound implements BindingChecker. It matches on the APIExport the provider declares, not on the binding's name: a tenant names its bindings, and a name is not a claim about what is bound.

type BindingChecker

type BindingChecker interface {
	IsBound(clusterID, provider string) (bool, error)
}

BindingChecker answers whether a provider's API is bound in a tenant workspace. A foreign-group rule is refused when the owning provider is not enabled there: minting a capability for an API the tenant never accepted would let one provider pull another into a workspace by the back door.

type DynamicOwnerProbe

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

DynamicOwnerProbe reads owner objects in a tenant workspace with the hub's own kcp credential — the same way the workload exchange re-reads the Project it is asked to mint for, and for the same reason: attestation proves who is asking, never what they are asking about.

func NewDynamicOwnerProbe

func NewDynamicOwnerProbe(kcpConfig *rest.Config) (*DynamicOwnerProbe, error)

NewDynamicOwnerProbe builds a probe over the hub's kcp config.

func NewOwnerProbeForClient

func NewOwnerProbeForClient(client dynamic.Interface) *DynamicOwnerProbe

NewOwnerProbeForClient is a focused test seam: it never changes the rules.

func (*DynamicOwnerProbe) Exists

func (p *DynamicOwnerProbe) Exists(ctx context.Context, clusterID string, owner Owner) (bool, string, error)

Exists implements OwnerProbe. Owner objects are cluster-scoped in every consumer this service has (Agent, the edge kinds, Project, the factory binding), which is also what makes a single dynamic Get enough.

func (*DynamicOwnerProbe) Observe added in v0.2.16

func (p *DynamicOwnerProbe) Observe(ctx context.Context, clusterID string, owner Owner) (bool, OwnerObservation, error)

Observe implements OwnerRevisionProbe. In addition to the owner UID it returns the generation and opaque resourceVersion from the same GET, so a versioned identity request is bound to the exact object snapshot its rules were derived from.

type Options

type Options struct {
	// Records is the ScopedIdentity store.
	Records RecordStore
	// Clients builds a kube client for one tenant logical cluster.
	Clients serviceaccounts.ClusterClientFactory
	// Policy decides which rules a provider may ask for.
	Policy *Policy
	// Owners probes owner objects. Required for the provider-asserted path.
	Owners OwnerProbe
	// Now is overridable in tests.
	Now func() time.Time
}

Options configures a Service.

type Owner

type Owner struct {
	Provider  string `json:"provider"`
	Kind      string `json:"kind"`
	Group     string `json:"group,omitempty"`
	Version   string `json:"version"`
	Resource  string `json:"resource"`
	Name      string `json:"name"`
	UID       string `json:"uid,omitempty"`
	ClusterID string `json:"clusterID,omitempty"`
}

Owner is the tenant object an identity exists for.

type OwnerObservation added in v0.2.16

type OwnerObservation struct {
	UID             string
	Generation      int64
	ResourceVersion string
}

OwnerObservation is a fresh read of an owner object. ResourceVersion is opaque: consumers compare it for equality and must not infer ordering from its representation.

type OwnerProbe

type OwnerProbe interface {
	// Exists returns (found, uid, error). A found object with a different UID
	// is reported as found=false: the owner was deleted and recreated, and the
	// identity minted for the old one must not survive.
	Exists(ctx context.Context, clusterID string, owner Owner) (bool, string, error)
}

OwnerProbe answers whether an owner object still exists in a tenant workspace, with the UID the record was minted for. It is both the admission check on the provider-asserted path and the GC predicate.

type OwnerRevision added in v0.2.16

type OwnerRevision struct {
	Generation      int64
	ResourceVersion string
}

OwnerRevision is the Project snapshot a provider used to calculate rules. ResourceVersion is intentionally treated as an opaque equality token.

type OwnerRevisionProbe added in v0.2.16

type OwnerRevisionProbe interface {
	Observe(ctx context.Context, clusterID string, owner Owner) (bool, OwnerObservation, error)
}

OwnerRevisionProbe is an optional extension implemented by probes that can return the complete owner revision. Versioned identity requests fail closed when the configured probe does not implement it.

type Policy

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

Policy decides which requested rules a provider may have.

func NewPolicy

func NewPolicy(catalog ProviderCatalog, bindings BindingChecker) *Policy

NewPolicy builds a policy. bindings may be nil, in which case foreign-group rules are refused outright: a hub that cannot tell whether a provider is bound must not assume it is.

func (*Policy) Authorize

func (p *Policy) Authorize(requester, clusterID string, requested []rbacv1.PolicyRule) ([]rbacv1.PolicyRule, error)

Authorize returns the normalized rules requester may be granted in clusterID, or the first refusal. Rules are returned sorted and deduplicated so an identical request always produces an identical ClusterRole, which is what makes the reconciler's rule comparison stable.

type ProviderAttestor

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

ProviderAttestor authenticates a provider-asserted request. It is deliberately the SAME check the heartbeat performs — bearer → TokenReview in the provider's own workspace → subject must be system:serviceaccount:default:provider (pkg/hub/providers/heartbeat_auth.go) — because a provider that can prove it is itself for a heartbeat can prove it is itself for an identity request, and one verifier is one thing to get right. The heartbeat authenticator's positive and negative caches come along with it, which matters: this endpoint is reached on every token refresh.

func NewProviderAttestor

func NewProviderAttestor(authenticate providers.HeartbeatAuthenticator) *ProviderAttestor

NewProviderAttestor wraps a heartbeat authenticator.

func (*ProviderAttestor) Attest

func (a *ProviderAttestor) Attest(ctx context.Context, r *http.Request, provider string) (string, error)

Attest verifies that r's bearer is provider's own service account and returns the authenticated subject.

type ProviderCatalog

type ProviderCatalog interface {
	// GroupOwner returns the provider that exports apiGroup.
	GroupOwner(apiGroup string) (string, bool)
	// ExportedGroups returns the API groups provider exports.
	ExportedGroups(provider string) []string
	// DeclaredVerbs returns every coordinate provider declares on resource —
	// its verbs and its action names together. They are the only
	// {resource}/{verb} subresources anybody may be granted create on.
	DeclaredVerbs(provider, resource string) []string
}

ProviderCatalog is the registry seam the policy reads: which groups a provider exports, and which verbs it declares on which resources. pkg/hub/providers.Registry satisfies it through RegistryCatalog.

type Reconciler

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

Reconciler materializes ScopedIdentity records and garbage-collects the ones whose owner is gone.

func NewReconciler

func NewReconciler(opts ReconcilerOptions) *Reconciler

NewReconciler builds a Reconciler.

func (*Reconciler) Start

func (r *Reconciler) Start(ctx context.Context)

Start runs the sweep until ctx is done. It is safe to run on every hub replica: every action it takes is idempotent, and two replicas collecting the same orphan both succeed.

func (*Reconciler) Sweep

func (r *Reconciler) Sweep(ctx context.Context) (SweepResult, error)

Sweep walks every record once: it collects the ones whose owner is gone and re-materializes the ones whose rules have drifted or whose token window has lapsed.

type ReconcilerOptions

type ReconcilerOptions struct {
	Service        *Service
	WorkloadScopes WorkloadScopeResolver
	Interval       time.Duration
	Logger         logr.Logger
}

ReconcilerOptions configures a Reconciler.

type RecordStore

RecordStore reads and writes ScopedIdentity records in root:railgrid:system:tenants.

type Refusal

type Refusal struct {
	Rule   rbacv1.PolicyRule `json:"rule"`
	Reason string            `json:"reason"`
	Code   string            `json:"code"`
}

Refusal explains why one requested rule was not allowed. It is returned to the requesting provider: a provider debugging its own identity request needs to know which rule failed and why, and nothing here reveals tenant state.

func (Refusal) Error

func (r Refusal) Error() string

Error implements error so a single refusal can be returned as one.

type RegistryCatalog

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

RegistryCatalog adapts the hub provider registry to ProviderCatalog.

A provider's exported groups are Provider.APIGroups: the groups the catalog controller READ from spec.resources[].group on the provider's own APIExport. They are emphatically NOT the APIExport's name. An export is named after the provider that serves it — `edges.providers.railgrid.ai` — while the kinds it serves live in `edges.railgrid.ai`, and a single export may serve several groups. Answering "who owns this group" from the export name therefore misses every provider whose two names differ (edges, infrastructure, code), which is most of them: the edges agent asking for its own `edges.railgrid.ai` was told unknown_group.

A provider whose export the hub has not read yet has no groups, and the answer here is "nobody owns it" — the policy then refuses the rule with unknown_group. That is the fail-closed direction: a guess would hand one provider's group to another provider's identity.

Declared verbs come from the export: every verb and action is declared ON the resource it is served on, which is exactly the {resource}/{verb} coordinate clause C mints.

Declared verbs are the union of two declarations that land on the same RBAC coordinate: ACTIONS (spec.export.resources[].actions — versioned, schema'd, request/response) and VERBS (spec.export.resources[].verbs — unversioned, streaming or proxying: exec, ssh, proxy, delegate). Both are enforced provider-side by a caller-scoped SSAR on {resource}/{verb}; the declaration is what makes the coordinate machine-readable so the hub can tell a real verb from an invented one.

func NewRegistryCatalog

func NewRegistryCatalog(registry *providers.Registry) *RegistryCatalog

NewRegistryCatalog adapts a provider registry.

func (*RegistryCatalog) DeclaredVerbs

func (c *RegistryCatalog) DeclaredVerbs(provider, resource string) []string

DeclaredVerbs implements ProviderCatalog.

func (*RegistryCatalog) ExportedGroups

func (c *RegistryCatalog) ExportedGroups(provider string) []string

ExportedGroups implements ProviderCatalog.

func (*RegistryCatalog) GroupOwner

func (c *RegistryCatalog) GroupOwner(apiGroup string) (string, bool)

GroupOwner implements ProviderCatalog.

The registry spans scopes, so an org-owned provider and a platform one could both claim a group. The platform copy wins and ties below it break on name, so the answer is the same on every replica and on every call — a map-order answer would make one identity request succeed and the next one refuse.

type Request

type Request struct {
	// Owner is the tenant object this identity is for.
	Owner Owner `json:"owner"`
	// ClusterID is the tenant workspace to materialize in. Empty means
	// Owner.ClusterID.
	ClusterID string `json:"clusterID,omitempty"`
	// Rules are the policy rules asked for; they are checked before anything
	// is written.
	Rules []rbacv1.PolicyRule `json:"rules,omitempty"`
	// TTLSeconds is the requested token lifetime, clamped to at most 24h.
	TTLSeconds int64 `json:"ttlSeconds,omitempty"`
	// ExpectedOwnerGeneration and ExpectedOwnerResourceVersion bind a request
	// to the exact owner snapshot from which Rules were derived. ResourceVersion
	// is opaque and is compared only for equality. They are optional for legacy
	// providers, but must be supplied together.
	ExpectedOwnerGeneration      int64  `json:"expectedOwnerGeneration,omitempty"`
	ExpectedOwnerResourceVersion string `json:"expectedOwnerResourceVersion,omitempty"`
}

Request is one create-or-refresh. It is idempotent on the owner tuple: asking twice returns the same ServiceAccount with a fresh token.

type RevisionConflict added in v0.2.16

type RevisionConflict struct {
	Code   string
	Reason string
}

RevisionConflict reports a stale owner snapshot or a bounded identity record write race. These stable codes let clients retry from a fresh owner read without reusing the old token source.

func (RevisionConflict) Error added in v0.2.16

func (e RevisionConflict) Error() string

type RuleClass

type RuleClass string

RuleClass names which policy clause admitted a rule. It is recorded on the refusal reason and in tests.

const (
	// ClassOwnGroup is clause A.
	ClassOwnGroup RuleClass = "own-group"
	// ClassForeignRead is clause B.
	ClassForeignRead RuleClass = "foreign-read"
	// ClassForeignVerb is clause C.
	ClassForeignVerb RuleClass = "foreign-verb"
	// ClassPlatform is clause D.
	ClassPlatform RuleClass = "platform"
)

type Service

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

Service is the scoped-identity service.

func New

func New(opts Options) *Service

New constructs a Service.

func (*Service) Ensure

Ensure creates or refreshes the identity for req, attested as mode by subject, and returns a fresh token. It is idempotent: the same owner tuple always yields the same ServiceAccount, and repeating the call with the same rules rewrites nothing.

func (*Service) EnsureWorkload

func (s *Service) EnsureWorkload(ctx context.Context, clusterID string, scope serviceaccounts.WorkloadIdentityScope, subject string) (*Token, error)

EnsureWorkload records and mints a pod-attested workload identity for a verified Project scope. clusterID is the tenant workspace (the workload exchange addresses it by path, which is a valid /clusters/{…} segment).

The rules and the account name come from serviceaccounts.WorkloadIdentityShape, unchanged: they are derived from the verified Project, never from anything the caller sent, so the rule policy that governs provider-asserted requests does not apply and is deliberately not run here.

func (*Service) List

func (s *Service) List(ctx context.Context, provider, clusterID string, owner *Owner) ([]tenancyv1alpha1.ScopedIdentity, error)

List returns a provider's records, optionally narrowed to one owner. Tokens are never part of a listing: there is nothing to list, because none is kept.

func (*Service) Release

func (s *Service) Release(ctx context.Context, clusterID string, owner Owner) error

Release deletes the identity for an owner: the record, and with it the ServiceAccount (which revokes every outstanding token), the ClusterRole and the binding. It is idempotent.

func (*Service) ReleaseByName

func (s *Service) ReleaseByName(ctx context.Context, provider, name string) error

ReleaseByName deletes one record by its object name, after checking that it belongs to provider. A provider may only delete its own identities.

type SweepResult

type SweepResult struct {
	Examined    int
	Collected   int
	Rematerial  int
	Failed      int
	SkippedWait int
}

SweepResult counts what one pass did. Returned for tests and logging.

type Token

type Token struct {
	Token          string    `json:"token"`
	TokenType      string    `json:"tokenType"`
	ExpiresAt      time.Time `json:"expiresAt"`
	ServiceAccount string    `json:"serviceAccount"`
	Name           string    `json:"name"`
	// OwnerRevisionVerified is true only when the hub checked the request's
	// expected owner revision and applied the corresponding fenced identity.
	// It is omitted for legacy unversioned requests.
	OwnerRevisionVerified bool `json:"ownerRevisionVerified,omitempty"`
}

Token is what a caller gets back. The hub keeps no copy.

type WorkloadScopeResolver added in v0.2.20

type WorkloadScopeResolver interface {
	ResolveRecord(context.Context, *tenancyv1alpha1.ScopedIdentity) (serviceaccounts.WorkloadIdentityScope, error)
}

WorkloadScopeResolver re-reads the Project backing a recorded workload identity and derives its current permission scope. It lets the normal hub sweep apply Project integration grants and revocations to existing identities without waiting for a workload to exchange its token again. It must use a live Project read rather than an informer cache: the reconciler captures the Project revision immediately before calling it and verifies that revision again before persisting a revoked scope.

Jump to

Keyboard shortcuts

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