tenant

package
v0.2.21 Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

Documentation

Overview

Package tenant implements the hub-side tenant middleware described in docs/organizations.md §Switch the active context. It resolves the active Organization + Workspace from request headers (X-Railgrid-Org and X-Railgrid-Workspace), validates them against the caller's UserMembershipIndex, and stuffs the resolved (user, orgUUID, workspaceUUID, role) tuple into the request context for downstream handlers to consume.

PR #6 ships the middleware as a library only: the package exposes the middleware + context helpers + the UserResolver / MembershipLookup interfaces. Wiring it into actual /api/* routes lands in PR #10 when the hub-mediated REST surface goes in.

Index

Constants

View Source
const (
	// HeaderRailgridOrg carries the active Organization UUID. Required for
	// any endpoint mounted behind this middleware.
	HeaderRailgridOrg = "X-Railgrid-Org"

	// HeaderRailgridWorkspace carries the active child Workspace UUID.
	// Optional: Org-scoped endpoints can omit it; Workspace-scoped
	// endpoints must include it (callers downstream of the middleware
	// can branch on tc.WorkspaceUUID == "").
	HeaderRailgridWorkspace = "X-Railgrid-Workspace"
)

Variables

View Source
var ErrUserNotResolved = errors.New("tenant: caller is not authenticated")

ErrUserNotResolved is returned by a UserResolver when the request carries no authenticated identity. The middleware translates this into a 401 Unauthorized response.

View Source
var ErrUserRecordUnavailable = errors.New("tenant: caller authenticated but user record unavailable")

ErrUserRecordUnavailable signals that the caller's credential was ACCEPTED but the backing User record could not be read. The caller is authenticated; the hub just cannot say who they are right now.

Resolvers should return this (or wrap it) instead of a bare error so middleware can distinguish "not authenticated" from "authenticated, backend struggling". Without the distinction the only options are refusing valid callers during a hiccup or admitting invalid ones, and neither is acceptable.

Functions

func Middleware

func Middleware(userResolver UserResolver, lookup MembershipLookup) func(next http.Handler) http.Handler

Middleware returns the tenant-context HTTP middleware. The returned function wraps an http.Handler chain so handlers downstream of it can trust TenantContext from r.Context().

The middleware performs these steps in order:

  1. Calls userResolver to identify the caller. 401 on ErrUserNotResolved; 500 on any other error.
  2. Reads X-Railgrid-Org; 400 if missing.
  3. Reads X-Railgrid-Workspace (optional).
  4. Calls lookup to fetch UserMembershipIndex for the user. 403 on "not found"; 500 on other errors.
  5. Walks index.spec.entries looking for a (OrgUUID, WorkspaceUUID) match where WorkspaceUUID can be empty (org-scope request) or equal to the header value (workspace-scope request). A workspace-scope request with no workspace row still matches when the caller holds a live org-scope admin row: an Org admin is implicitly admin in every child Workspace (docs/organizations.md O-15). Org members get no such implicit grant.
  6. On match, attaches a TenantContext via WithContext and invokes next; on no match, returns 403.

The middleware is intentionally side-effect-free aside from setting the request context. It does not mutate response headers (other than on error) and does not impose a content-type.

func OptionalOrgMiddleware

func OptionalOrgMiddleware(userResolver UserResolver, lookup MembershipLookup) func(next http.Handler) http.Handler

OptionalOrgMiddleware requires an authenticated caller but treats the Organization as optional.

It backs GET /api/providers, where the catalog is per-caller — platform providers for everyone, plus the caller's own Org's providers when they present one they actually belong to. The Org has to be optional because the portal fetches the catalog before an Org is selected; the identity does not, because the catalog describes what this deployment runs and should not be enumerable by anyone who can reach the hub.

Rejections:

  • No credential, or one that fails verification: 401. This is the case that keeps the catalog from being scraped anonymously.

Degradations (request proceeds with less context, never more):

  • Credential accepted but the User record is unavailable (ErrUserRecordUnavailable): proceed with no context. The caller is legitimate, so serving them the platform catalog is right; failing here would take the catalog down whenever the user store hiccups, which is a question this endpoint does not need answered.
  • An Org the caller is not a member of, or a membership lookup that fails: drop the Org. A stale Org UUID in a browser's localStorage must not break the app shell, and dropping it can only ever show less.

Handlers behind this must treat an empty OrgUUID as "global scope only" and never as "trusted". Anything needing a known user identity belongs behind Middleware or UserOnlyMiddleware instead.

func UserOnlyMiddleware

func UserOnlyMiddleware(userResolver UserResolver) func(next http.Handler) http.Handler

UserOnlyMiddleware returns an HTTP middleware that resolves the caller via userResolver and stashes a TenantContext with only the User field populated. Used by endpoints that don't need an active Org / Workspace context — chiefly the Org-list / Org-create surface (no Org exists yet to claim membership in) and User self-service endpoints.

Errors:

  • 401 on ErrUserNotResolved
  • 500 on any other resolver error

func WithContext

func WithContext(ctx context.Context, tc TenantContext) context.Context

WithContext returns a copy of ctx that carries tc. Used by the middleware to attach the resolved triple before invoking the next handler, and by tests to inject a synthetic TenantContext.

func WithDelegatedCall

func WithDelegatedCall(ctx context.Context, call DelegatedCall) context.Context

WithDelegatedCall attaches an admitted delegated call to ctx.

Types

type DelegatedCall

type DelegatedCall struct {
	// User is the person the token stands for (User CR name).
	User string
	// RBACIdentity, Groups, and Extra are the identity fields verified by the
	// hub action proof. They are populated only for proof-backed calls and let
	// narrow handlers ask SAR about the original human without trusting
	// request headers supplied by a provider.
	RBACIdentity string
	Groups       []string
	Extra        map[string][]string
	// ActionUser and ActionAppID are optional action-proof intent fields for
	// narrowly bound App Studio account resolution.
	ActionUser  string
	ActionAppID string
	// AppAccess* are the exact app-access operation and persisted object
	// coordinates signed into an App Studio action proof.
	AppAccessOperation    string
	AppAccessGroup        string
	AppAccessResource     string
	AppAccessName         string
	AppAccessUID          string
	AppAccessProject      string
	AppAccessProjectUID   string
	AppAccessUser         string
	AppAccessGrant        string
	AppAccessSourceVerb   string
	AppAccessSourceMethod string
	AppAccessInviteUser   string
	// Provider and ProviderOrgUUID identify the provider ("" org = platform).
	Provider        string
	ProviderOrgUUID string
	// Capability and Scope are the hub capability the route maps to.
	Capability string
	Scope      string
	// MaxRole caps the role a membership write may grant.
	MaxRole string
	// AllowInvite permits pre-provisioning an unknown email.
	AllowInvite bool
	// ActionProof marks a caller identity established by a short-lived proof
	// minted at the authenticated kcp front door, rather than a delegated
	// ServiceAccount token. The tenant resolver must prefer this verified
	// person over the provider's bearer identity.
	ActionProof bool
	// ActionProofNonce identifies the proof after its mutating capability use
	// was durably claimed by the hub. It is empty for replayable GET calls.
	ActionProofNonce string
}

DelegatedCall marks a request made by a provider that the hub-access gate (pkg/hub/hubaccess) admitted: the provider, the capability the route maps to, and the limits the tenant accepted. Handlers read it to apply those limits on top of the person's own role; a request without one is the person's own call.

func DelegatedCallFrom

func DelegatedCallFrom(ctx context.Context) (DelegatedCall, bool)

DelegatedCallFrom returns the admitted delegated call, if the request is one.

type MembershipLookup

type MembershipLookup interface {
	GetUserMembershipIndex(ctx context.Context, userName string) (*tenancyv1alpha1.UserMembershipIndex, error)
}

MembershipLookup reads the UserMembershipIndex CR for the named user. Implementations typically wrap a Kubernetes/kcp dynamic or typed client targeting root:railgrid:users.

Returning a Kubernetes "not found" error (apierrors.IsNotFound) is the convention for "this user has no memberships yet" — the middleware turns that into a 403 because the caller is authenticated but holds no Memberships, which is functionally the same as "you can't access this Org". Any other error is treated as a 500.

type MembershipLookupFunc

type MembershipLookupFunc func(ctx context.Context, userName string) (*tenancyv1alpha1.UserMembershipIndex, error)

MembershipLookupFunc adapts a function to the MembershipLookup interface.

func (MembershipLookupFunc) GetUserMembershipIndex

func (f MembershipLookupFunc) GetUserMembershipIndex(ctx context.Context, userName string) (*tenancyv1alpha1.UserMembershipIndex, error)

GetUserMembershipIndex implements MembershipLookup.

type TenantContext

type TenantContext struct {
	// User is the metadata.name (UUID) of the caller's User CR.
	User string

	// OrgUUID is the metadata.name (UUID) of the caller's active
	// Organization, taken from X-Railgrid-Org. Always set in a successful
	// TenantContext.
	OrgUUID string

	// WorkspaceUUID is the metadata.name (UUID) of the caller's active
	// child Workspace, taken from X-Railgrid-Workspace. Empty for
	// Org-scoped requests.
	WorkspaceUUID string

	// Role is the granted role for the matching Membership: "admin" or
	// "member". For Workspace-scoped requests this is the role from the
	// workspace-scope Membership, or "admin" when the caller holds no
	// workspace row but is an admin of the Org (Org admins are implicit
	// admins in every child Workspace, docs/organizations.md O-15); for
	// Org-scoped requests it is the role from the org-scope Membership.
	// Validated against MembershipRole* constants in apis/tenancy/v1alpha1.
	Role string

	// OrgRole is the caller's ORG-scope role in OrgUUID, independent of any
	// X-Railgrid-Workspace header: the role of the org-scope Membership, or ""
	// when the caller holds none (a workspace-only member). Org-scope routes
	// must authorize against this, never against Role: when a request names
	// a workspace, Role is that workspace's role, and a workspace admin is
	// not an org admin.
	OrgRole string
}

TenantContext captures the result of a successful pass through the tenant middleware: the caller's User CR name plus the Organization-Workspace-Role triple they're claiming via headers.

WorkspaceUUID is empty for Org-scoped requests (the caller sent X-Railgrid-Org without X-Railgrid-Workspace — valid for org-management endpoints).

func FromContext

func FromContext(ctx context.Context) (TenantContext, bool)

FromContext returns the TenantContext attached to ctx by the middleware, or (zero, false) if none is present. Handlers downstream of the middleware can rely on ok=true. Handlers reachable without the middleware should treat ok=false as "anonymous or unscoped" and behave accordingly.

func ResolveWorkspaceContext added in v0.2.9

func ResolveWorkspaceContext(ctx context.Context, lookup MembershipLookup, user, org, workspace string) (TenantContext, error)

ResolveWorkspaceContext checks current membership for a previously authenticated identity. The caller must establish the identity independently; a request header is not an identity. It shares the middleware's live-entry and implicit org-admin rules, including rejection of soft-deleted memberships.

type UserResolver

type UserResolver interface {
	ResolveUser(r *http.Request) (string, error)
}

UserResolver extracts the caller's User CR name from the request. Implementations are typically thin wrappers over the hub's existing auth layer (OIDC bearer-token validation, static-token lookups, or kcp ServiceAccount claims).

Returning ErrUserNotResolved signals "no authenticated identity"; the middleware turns that into a 401. Any other error is treated as a 500 because it indicates a backend failure rather than a missing caller.

type UserResolverFunc

type UserResolverFunc func(r *http.Request) (string, error)

UserResolverFunc adapts a function to the UserResolver interface.

func (UserResolverFunc) ResolveUser

func (f UserResolverFunc) ResolveUser(r *http.Request) (string, error)

ResolveUser implements UserResolver.

Jump to

Keyboard shortcuts

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