Documentation
¶
Overview ¶
Package scim implements a per-organization inbound SCIM 2.0 server for user provisioning and deprovisioning (RFC 7643/7644, users only). A customer's IdP (Okta, Entra, …) authenticates with a per-org bearer token and the org it may act on is derived ONLY from the matched endpoint — never from the URL or payload (design §4.4 H6, the C3 confused-deputy class).
ponytail: users only. SCIM Groups → org-namespaced FGA roles is deferred to a follow-up (needs the FGA engine + org-admin permission model, design §4.4 CR2). Also deferred: ETag/versioning and pagination cursors.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // endpoint. Map to 401. Deliberately generic: it never distinguishes an // unknown endpoint id from a wrong secret (constant-time, dummy-compare). ErrUnauthorized = errors.New("scim: unauthorized") // ErrNotFound — the resource does not exist OR belongs to another org (H6: // a cross-org id is indistinguishable from a non-existent one). Map to 404. ErrNotFound = errors.New("scim: resource not found") // ErrConflict — a create collides with an existing userName owned outside // this org (global email uniqueness). Map to 409. ErrConflict = errors.New("scim: userName already exists") // ErrInvalid — malformed input (e.g. missing userName). Map to 400. ErrInvalid = errors.New("scim: invalid request") // configured (group membership is stored as FGA tuples). Map to 501. ErrGroupsUnavailable = errors.New("scim: group provisioning requires the authorization engine") // ErrGroupConflict — a Group create/rename collides with an existing // displayName in the org (RFC 7644 §3.3 uniqueness). Map to 409/uniqueness. // Distinct from ErrConflict, whose message names userName. ErrGroupConflict = errors.New("scim: displayName already exists") )
Sentinel errors the transport maps to SCIM status codes.
Functions ¶
func GenerateToken ¶
GenerateToken builds "<endpointID>.<hexSecret>" with 256 bits of entropy and returns the plaintext plus its bcrypt hash. Only the hash is persisted; the plaintext is revealed once at create/rotate. Stateless — the admin surface calls it directly when provisioning an endpoint.
Types ¶
type Dependencies ¶
type Dependencies struct {
Log *zerolog.Logger
StorageProvider storage.Provider
MemoryStoreProvider memory_store.Provider
// AuthzEngine is the FGA engine. SCIM Group membership is stored as FGA
// relationship tuples (not a DB column), so the Group ops require it. Nil
// when FGA is not configured — Group ops then return ErrGroupsUnavailable.
AuthzEngine engine.AuthorizationEngine
// EventsProvider dispatches provisioning-lifecycle webhooks
// (user.provisioned/deprovisioned/scim_updated, group.created/updated/deleted).
// Nil when webhooks are not wired — event firing is then a no-op.
EventsProvider events.Provider
}
Dependencies for the SCIM service.
type Group ¶
Group is the transport-neutral SCIM group projection the handler maps to/from SCIM JSON. Members are Authorizer user ids (the SCIM member "value").
type MemberOp ¶
type MemberOp struct {
Op string // "add" | "remove" | "replace"
Members []string
// ClearAll marks an unfiltered full-membership clear: a `remove` op whose
// `members` key is present with an empty/absent value (RFC 7644 §3.5.2 — the
// deprovisioning shape an IdP sends to empty a group). Without this flag such
// an op carries no member ids and would be a silent no-op. (A `replace` with
// an empty set already clears via replaceMembers, so ClearAll is only read on
// `remove`.)
ClearAll bool
}
MemberOp is a single parsed SCIM PatchOp entry over the `members` attribute. Op is lower-cased (RFC 7644 §3.5.2 says op is case-insensitive; real IdPs send mixed case). Members are Authorizer user ids extracted from either the RFC/Okta filtered-path shape or the Entra value-array shape (see the handler's parser).
type Provider ¶
type Provider interface {
// Authenticate verifies the presented bearer token and returns the org it
// authorizes. The org is derived solely from the matched endpoint.
Authenticate(ctx context.Context, bearer string) (orgID string, err error)
// CreateUser provisions a user into the org. Idempotent: a repeat with the
// same externalId (or an existing org member with the same userName) returns
// the existing user with existed=true and creates no duplicate.
CreateUser(ctx context.Context, orgID string, in User) (user *schemas.User, existed bool, err error)
// GetUser fetches an org member by id (404 if not a member — H6).
GetUser(ctx context.Context, orgID, userID string) (*schemas.User, error)
// FindByUserName returns the org member with the given userName, or nil when
// none (the IdP's pre-create dedup probe). Never leaks another org's user.
FindByUserName(ctx context.Context, orgID, userName string) (*schemas.User, error)
// ListUsers evaluates a parsed single-term SCIM filter against the org's
// members and returns the matches (org-scoped — never another org's users).
// eq on userName/emails.value/externalId is an indexed lookup; other
// operators/attributes scan the org's memberships (bounded).
ListUsers(ctx context.Context, orgID string, filter UserFilter) ([]*schemas.User, error)
// ReplaceUser (PUT) overwrites the mutable profile + active flag of an org
// member. A true→false active transition revokes the user's sessions.
ReplaceUser(ctx context.Context, orgID, userID string, in User) (*schemas.User, error)
// SetActive (PATCH active / DELETE) flips the active flag. Deactivation
// synchronously revokes the user's sessions + refresh tokens.
SetActive(ctx context.Context, orgID, userID string, active bool) (*schemas.User, error)
// PatchUser applies a parsed SCIM User PatchOp: any non-nil field in patch is
// updated. Email/phone changes are uniqueness-checked (ErrConflict on a
// collision with another user); an active:true→false transition revokes the
// user's sessions. Returns the user unchanged (no event) when nothing changed.
PatchUser(ctx context.Context, orgID, userID string, patch UserPatch) (*schemas.User, error)
// CreateGroup provisions a group into the org. When the payload carries an
// externalId that already identifies a group in the org, the create is
// idempotent: it adopts a renamed displayName and returns existed=true. A
// create that instead clashes on displayName (no matching externalId) is a
// uniqueness conflict (ErrGroupConflict → 409), not a silent 200. Any
// in.Members are added (org-membership-gated).
CreateGroup(ctx context.Context, orgID string, in Group) (group *schemas.ScimGroup, existed bool, err error)
// GetGroup fetches an org's group by id (404 if it belongs to another org — H6).
GetGroup(ctx context.Context, orgID, groupID string) (*schemas.ScimGroup, error)
// FindGroupByDisplayName returns the org's group with the given displayName,
// or nil when none (the IdP's `displayName eq` probe). Never leaks another org.
FindGroupByDisplayName(ctx context.Context, orgID, displayName string) (*schemas.ScimGroup, error)
// ReplaceGroup (PUT) overwrites displayName and sets membership to exactly
// in.Members (org-membership-gated).
ReplaceGroup(ctx context.Context, orgID, groupID string, in Group) (*schemas.ScimGroup, error)
// PatchGroup applies a parsed SCIM PatchOp: optional displayName / externalId
// changes plus member add/remove/replace ops. A remove op with ClearAll (an
// unfiltered "remove members") or a replace with an empty set removes every
// member. Idempotent.
PatchGroup(ctx context.Context, orgID, groupID string, displayName, externalID *string, ops []MemberOp) (*schemas.ScimGroup, error)
// DeleteGroup removes the group row and all its membership + role-binding
// tuples.
DeleteGroup(ctx context.Context, orgID, groupID string) error
// GroupMembers returns the Authorizer user ids that are direct members of an
// org's group (for the SCIM `members` response).
GroupMembers(ctx context.Context, orgID, groupID string) ([]string, error)
}
Provider is the org-bounded SCIM operation surface. Every method takes the orgID resolved by Authenticate — callers MUST NOT source it from the request path or body (H6).
type User ¶
type User struct {
ExternalID string
UserName string // SCIM userName → the user's email
GivenName string
FamilyName string
Active bool
}
User is the transport-neutral SCIM user projection the handler maps to/from SCIM JSON. Only the attributes Okta/Entra send for provisioning are modelled.
type UserFilter ¶
UserFilter is a parsed single-term SCIM filter (RFC 7644 §3.4.2.2). Attribute is one of the canonical names ListUsers understands (userName, emails.value, name.givenName, name.familyName, active, externalId); Operator is one of eq/ne/co/sw/pr; Value is empty for pr.
type UserPatch ¶
type UserPatch struct {
GivenName *string
FamilyName *string
Email *string
PhoneNumber *string
ExternalID *string
Active *bool
}
UserPatch is a parsed SCIM User PatchOp (RFC 7644 §3.5.2). Every field is a pointer so "attribute absent from the patch" is distinguishable from "set to empty": only non-nil fields are applied.