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
- Variables
- func Middleware(userResolver UserResolver, lookup MembershipLookup) func(next http.Handler) http.Handler
- func OptionalOrgMiddleware(userResolver UserResolver, lookup MembershipLookup) func(next http.Handler) http.Handler
- func UserOnlyMiddleware(userResolver UserResolver) func(next http.Handler) http.Handler
- func WithContext(ctx context.Context, tc TenantContext) context.Context
- func WithDelegatedCall(ctx context.Context, call DelegatedCall) context.Context
- type DelegatedCall
- type MembershipLookup
- type MembershipLookupFunc
- type TenantContext
- type UserResolver
- type UserResolverFunc
Constants ¶
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 ¶
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.
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:
- Calls userResolver to identify the caller. 401 on ErrUserNotResolved; 500 on any other error.
- Reads X-Railgrid-Org; 400 if missing.
- Reads X-Railgrid-Workspace (optional).
- Calls lookup to fetch UserMembershipIndex for the user. 403 on "not found"; 500 on other errors.
- 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.
- 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
// 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
}
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 ¶
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 ¶
UserResolverFunc adapts a function to the UserResolver interface.
func (UserResolverFunc) ResolveUser ¶
func (f UserResolverFunc) ResolveUser(r *http.Request) (string, error)
ResolveUser implements UserResolver.