scim

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: BSD-3-Clause Imports: 18 Imported by: 0

README

scim

Go Reference License CI

SCIM 2.0 provisioning, the service-provider half. An identity provider (Entra ID, Okta, Keycloak, …) creates, updates and deletes people and groups in your application over HTTP; this package is that HTTP endpoint, for Users and Groups (RFC 7643, RFC 7644), over a Backend you write. Pure Go, CGO_ENABLED=0, standard library only.

srv := &scim.Server{
	Backend: scim.NewMemory(),                     // or your database
	BaseURL: "https://app.example.org/scim/v2",    // for meta.location
}
mux.Handle("/scim/v2/", http.StripPrefix("/scim/v2", scim.BearerAuth(token, srv)))

What it serves

/Users, /Groups GET (list: filter, startIndex, count, attributes, excludedAttributes), POST (201 with Location)
/Users/{id}, /Groups/{id} GET (ETag, If-None-Match), PUT, PATCH, DELETE (If-Match on all three)
/Users/.search, /Groups/.search POST a SearchRequest, for queries that should not sit in a URL (RFC 7644 §3.4.3, §7.5.2)
/ServiceProviderConfig, /ResourceTypes, /Schemas discovery (RFC 7644 §4); a filter there is answered 403
  • Filters: the whole grammar of RFC 7644 §3.4.2.2 — eq ne co sw ew gt lt ge le pr, and or not, parentheses, value filters such as emails[type eq "work" and value co "@example.org"], schema-qualified paths, extension attributes. Names and operators without case; string values without case except where RFC 7643 says caseExact (id, externalId, $ref, meta.version, references' value).
  • PATCH: add, remove, replace with and without a path, value-filter paths with a sub-attribute (emails[type eq "work"].value), atomic: an operation that fails leaves the resource as it was. Concurrent writers are detected through the resource version, never overwritten.
  • Paging: 1-based startIndex, count clamped to MaxResults (RFC 7644 §3.4.2.4: below 1 is 1, a negative count is 0), in a stable order.
  • What identity providers really send, tested with their payloads: Entra ID's "Replace", its string booleans ("active": "False"), its member removal by value list, its filtered add that creates an email; Okta's replace without a path that repeats the id.

The Backend

type Backend interface {
	CreateUser(ctx, User) error
	GetUser(ctx, id) (User, error)                  // ErrNotFound
	ReplaceUser(ctx, User, ifVersion string) error  // ErrPrecondition when changed
	DeleteUser(ctx, id) error
	ListUsers(ctx, *Filter) ([]User, error)         // may ignore the filter
	// … and the same for Group
}

The Server assigns ids, timestamps and versions, validates input and applies PATCH; the Backend keeps what it is given and enforces what only it can see: ErrConflict for a taken userName (409 uniqueness), InvalidValue for a member that does not exist (400 invalidValue), ErrPrecondition when the stored version is not the one that was read. A list method may narrow by the filter — Filter.Equality() names the cheap case, userName eq "…" — or return everything: the Server applies the filter exactly to what comes back. NewMemory() is the reference implementation.

What it refuses

Everything here comes from outside, so the limits are part of the API:

a body over MaxBodyBytes (1 MiB), a PATCH with more than MaxOperations (100), or one that grows the resource past the body limit 413
a group with more than MaxMembers (10 000) 400 invalidValue
a filter over 4 KiB, nested deeper than 32, with an unknown operator, or gt on a boolean 400 invalidFilter
writes to id, meta, schemas or a user's groups 400 mutability
a resource without its core schema, a userName that is empty, over 256 bytes or has control characters, two primary emails 400
a missing or wrong bearer token (BearerAuth, constant time; an empty token admits nobody) 401
any Backend error that is not one of the sentinels 500 with a body that says nothing; the error goes to ErrorLog

Duplicate members are skipped in linear time, so a PATCH adding ten thousand members costs ten thousand map lookups rather than a hundred million comparisons. The filter, PATCH and the whole handler are fuzzed (FuzzFilter, FuzzPatch, FuzzServer).

Not supported: bulk operations, sorting, password changes, /Me (announced as such in /ServiceProviderConfig).

References

RFC 7643 (core schema), RFC 7644 (protocol): §3.4.2.2 filters, §3.4.2.4 paging, §3.5.2 PATCH, §3.12 errors, §3.14 versions, §4 discovery, §7 security.

Licence

BSD-3-Clause.

Documentation

Overview

Package scim is the service-provider half of SCIM 2.0: an HTTP handler for Users and Groups (RFC 7644) over a Backend the caller writes, with the core schemas (RFC 7643), the filter language, PATCH, paging and the discovery endpoints.

h := &scim.Server{Backend: scim.NewMemory(), BaseURL: "https://app.example.org/scim/v2"}
mux.Handle("/scim/v2/", http.StripPrefix("/scim/v2", scim.BearerAuth(token, h)))

An identity provider (Entra ID, Okta, Keycloak, authn-bridge…) is the client: it creates, updates and deletes people and groups here, and the application reads them from its Backend.

What this package refuses, because each is input from outside:

  • a filter longer than MaxFilterBytes, nested deeper than 32, or with an operator the attribute cannot take (400 invalidFilter);
  • a body larger than MaxBodyBytes, a PATCH with more than MaxOperations operations (413), a page larger than MaxResults (clamped);
  • writes to read-only attributes (id, meta, a user's groups: 400 mutability) and resources without their core schema;
  • a PATCH that does not apply in full: PATCH is atomic (RFC 7644 §3.5.2), and a concurrent change is detected through the resource version, not overwritten.

Errors from the Backend that are not this package's sentinels become a 500 whose body says nothing about them.

Index

Constants

View Source
const (
	UserSchema     = "urn:ietf:params:scim:schemas:core:2.0:User"
	GroupSchema    = "urn:ietf:params:scim:schemas:core:2.0:Group"
	ListResponse   = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
	ErrorSchema    = "urn:ietf:params:scim:api:messages:2.0:Error"
	PatchOpSchema  = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
	SPConfigSchema = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
	ResourceTypeSc = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
	SchemaSchema   = "urn:ietf:params:scim:schemas:core:2.0:Schema"
	// MediaType is the SCIM content type (RFC 7644 §8.1).
	MediaType = "application/scim+json"
)

Schema and message URNs (RFC 7643 §8.7, RFC 7644 §3).

View Source
const MaxFilterBytes = 4096

MaxFilterBytes bounds the length of a filter.

Variables

View Source
var (
	// ErrNotFound: no resource with that id (404).
	ErrNotFound = errors.New("scim: not found")
	// ErrConflict: a unique attribute (userName, a group's displayName if
	// the backend wants it unique) is taken (409 uniqueness).
	ErrConflict = errors.New("scim: uniqueness conflict")
	// ErrPrecondition: the resource changed since it was read (412).
	ErrPrecondition = errors.New("scim: resource changed")
)

Sentinel errors a Backend returns.

Functions

func BearerAuth

func BearerAuth(token string, next http.Handler) http.Handler

BearerAuth admits requests carrying "Authorization: Bearer <token>", compared in constant time, and answers others 401 with a SCIM error. An empty token admits nobody. The token should be long and random (RFC 7644 §7.4); TLS is the caller's to provide (§7.1).

Types

type Backend

type Backend interface {
	CreateUser(ctx context.Context, u User) error
	// GetUser returns a user with Groups filled in, or ErrNotFound.
	GetUser(ctx context.Context, id string) (User, error)
	// ReplaceUser stores u if the stored user's Meta.Version is ifVersion.
	ReplaceUser(ctx context.Context, u User, ifVersion string) error
	DeleteUser(ctx context.Context, id string) error
	ListUsers(ctx context.Context, f *Filter) ([]User, error)

	CreateGroup(ctx context.Context, g Group) error
	GetGroup(ctx context.Context, id string) (Group, error)
	ReplaceGroup(ctx context.Context, g Group, ifVersion string) error
	DeleteGroup(ctx context.Context, id string) error
	ListGroups(ctx context.Context, f *Filter) ([]Group, error)
}

Backend stores Users and Groups. The Server assigns ids, timestamps and versions, validates input and applies PATCH; the Backend keeps what it is given and enforces what only it can see: uniqueness (ErrConflict), that a member exists (InvalidValue), and that a replaced resource is still the version that was read (ErrPrecondition).

List methods receive the filter, or nil. A Backend may use it to narrow the result (Filter.Equality names the cheap cases), or ignore it: the Server applies the filter exactly to whatever comes back.

type Bool

type Bool bool

Bool is a boolean that also reads the strings "true" and "false" in any case, as some identity providers send them (Entra ID sends "False").

func (*Bool) UnmarshalJSON

func (b *Bool) UnmarshalJSON(data []byte) error

UnmarshalJSON reads true, false, "true" or "false".

type Email

type Email struct {
	Value   string `json:"value"`
	Type    string `json:"type,omitempty"`
	Display string `json:"display,omitempty"`
	Primary bool   `json:"primary,omitempty"`
}

Email is one of a user's addresses (RFC 7643 §4.1.2).

type Error

type Error struct {
	Status   int
	ScimType string
	Detail   string
}

Error is a SCIM error response (RFC 7644 §3.12).

func (*Error) Error

func (e *Error) Error() string

func (*Error) MarshalJSON

func (e *Error) MarshalJSON() ([]byte, error)

MarshalJSON writes the Error message, status as a string.

type Filter

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

Filter is a parsed filter.

func ParseFilter

func ParseFilter(src string) (*Filter, error)

ParseFilter parses a filter.

func (*Filter) Equality

func (f *Filter) Equality() (attr, value string, ok bool)

Equality reports whether the filter is a single `attr eq "value"` on a top-level attribute, so that a Backend can answer it from an index (userName, externalId, displayName). attr is lower-cased.

func (*Filter) Match

func (f *Filter) Match(resource map[string]any) bool

Match reports whether a resource, as a JSON object, matches.

func (*Filter) String

func (f *Filter) String() string

String returns the filter as it was written.

type Group

type Group struct {
	Schemas     []string `json:"schemas"`
	ID          string   `json:"id"`
	ExternalID  string   `json:"externalId,omitempty"`
	DisplayName string   `json:"displayName"`
	Members     []Ref    `json:"members,omitempty"`
	Meta        Meta     `json:"meta"`
}

Group is a core Group (RFC 7643 §4.2).

type InvalidValue

type InvalidValue string

InvalidValue is a Backend's refusal of a value, such as a member that does not exist (400 invalidValue). Its message is shown to the client.

func (InvalidValue) Error

func (e InvalidValue) Error() string

type Memory

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

Memory is a Backend in memory: tests, and the reference for writing another. userName is unique without regard to case (RFC 7643 §4.1.1), group display names are not unique, members must exist, and deleting a user or group removes it from every group.

func NewMemory

func NewMemory() *Memory

NewMemory returns an empty Memory backend.

func (*Memory) CreateGroup

func (m *Memory) CreateGroup(_ context.Context, g Group) error

func (*Memory) CreateUser

func (m *Memory) CreateUser(_ context.Context, u User) error

func (*Memory) DeleteGroup

func (m *Memory) DeleteGroup(_ context.Context, id string) error

func (*Memory) DeleteUser

func (m *Memory) DeleteUser(_ context.Context, id string) error

func (*Memory) GetGroup

func (m *Memory) GetGroup(_ context.Context, id string) (Group, error)

func (*Memory) GetUser

func (m *Memory) GetUser(_ context.Context, id string) (User, error)

func (*Memory) ListGroups

func (m *Memory) ListGroups(_ context.Context, _ *Filter) ([]Group, error)

func (*Memory) ListUsers

func (m *Memory) ListUsers(_ context.Context, _ *Filter) ([]User, error)

func (*Memory) ReplaceGroup

func (m *Memory) ReplaceGroup(_ context.Context, g Group, ifVersion string) error

func (*Memory) ReplaceUser

func (m *Memory) ReplaceUser(_ context.Context, u User, ifVersion string) error

type Meta

type Meta struct {
	ResourceType string    `json:"resourceType"`
	Created      time.Time `json:"created"`
	LastModified time.Time `json:"lastModified"`
	Location     string    `json:"location,omitempty"`
	// Version is a weak ETag, W/"…", computed by the Server.
	Version string `json:"version,omitempty"`
}

Meta is the provider-assigned metadata of a resource (RFC 7643 §3.1).

type Name

type Name struct {
	Formatted       string `json:"formatted,omitempty"`
	FamilyName      string `json:"familyName,omitempty"`
	GivenName       string `json:"givenName,omitempty"`
	MiddleName      string `json:"middleName,omitempty"`
	HonorificPrefix string `json:"honorificPrefix,omitempty"`
	HonorificSuffix string `json:"honorificSuffix,omitempty"`
}

Name is a user's name (RFC 7643 §4.1.1).

type PatchOp

type PatchOp struct {
	Op    string          `json:"op"`
	Path  string          `json:"path,omitempty"`
	Value json.RawMessage `json:"value,omitempty"`
}

PatchOp is one operation.

type PatchRequest

type PatchRequest struct {
	Schemas    []string  `json:"schemas"`
	Operations []PatchOp `json:"Operations"`
}

PatchRequest is a PATCH body.

type Ref

type Ref struct {
	Value   string `json:"value"`
	Ref     string `json:"$ref,omitempty"`
	Display string `json:"display,omitempty"`
	// Type is "User" or "Group" for a member, "direct" or "indirect" for a
	// user's group.
	Type string `json:"type,omitempty"`
}

Ref points at another resource: a group a user belongs to, or a member of a group.

type SearchRequest

type SearchRequest struct {
	Schemas            []string `json:"schemas"`
	Filter             string   `json:"filter,omitempty"`
	StartIndex         *int     `json:"startIndex,omitempty"`
	Count              *int     `json:"count,omitempty"`
	Attributes         []string `json:"attributes,omitempty"`
	ExcludedAttributes []string `json:"excludedAttributes,omitempty"`
}

SearchRequest is the body of POST /.search (RFC 7644 §3.4.3).

type Server

type Server struct {
	Backend Backend
	// BaseURL is the absolute URL of the SCIM root as clients see it, used
	// for meta.location and Location; empty gives relative locations.
	BaseURL string
	// MaxResults bounds a page (default 100); a larger count is clamped.
	MaxResults int
	// MaxBodyBytes bounds a request body (default 1 MiB).
	MaxBodyBytes int64
	// MaxOperations bounds the operations of one PATCH (default 100).
	MaxOperations int
	// MaxMembers bounds the members of one group (default 10000).
	MaxMembers int
	// Now and NewID replace the clock and the id generator (tests).
	Now   func() time.Time
	NewID func() string
	// ErrorLog receives the Backend errors that become a 500.
	ErrorLog func(err error)
}

Server is the SCIM HTTP handler. Mount it at the SCIM root (strip the prefix): it serves /Users, /Groups, their /.search, /ServiceProviderConfig, /ResourceTypes and /Schemas. It does no authentication: wrap it, for instance with BearerAuth.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP routes a request.

type User

type User struct {
	Schemas     []string `json:"schemas"`
	ID          string   `json:"id"`
	ExternalID  string   `json:"externalId,omitempty"`
	UserName    string   `json:"userName"`
	Name        *Name    `json:"name,omitempty"`
	DisplayName string   `json:"displayName,omitempty"`
	// Active is nil when the client never said; Enabled treats that as true.
	Active *Bool   `json:"active,omitempty"`
	Emails []Email `json:"emails,omitempty"`
	// Groups is read-only: the Backend fills it from group memberships.
	Groups []Ref `json:"groups,omitempty"`
	Meta   Meta  `json:"meta"`
}

User is a core User (RFC 7643 §4.1). Attributes this package does not model are ignored on input.

func (User) Enabled

func (u User) Enabled() bool

Enabled reports whether the user is active (absent counts as active).

Jump to

Keyboard shortcuts

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