scim

package
v0.0.0-...-4ad0758 Latest Latest
Warning

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

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

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

View Source
var (
	// ErrUnauthorized — missing/malformed/invalid bearer token, or a disabled
	// 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")
	// ErrGroupsUnavailable — a Group op was attempted but the FGA engine is not
	// 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

func GenerateToken(endpointID string) (plaintext, hash string, err error)

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

type Group struct {
	ExternalID  string
	DisplayName string
	Members     []string
}

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).

func New

func New(deps *Dependencies) Provider

New constructs a SCIM service provider.

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

type UserFilter struct {
	Attribute string
	Operator  string
	Value     string
}

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.

Jump to

Keyboard shortcuts

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