scim

package
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package scim is the wire protocol of SCIM 2.0 (RFC 7643 and RFC 7644) as the hub speaks it to an identity provider (Task 20415): the resource documents, the filter and attribute-path grammars, PATCH, list responses, errors and the three discovery documents.

It holds no policy and touches no storage. What a provisioned account means to the hub — which identity it is, what deactivating it severs, what a group grants — is pkg/provision's; this package only answers "what did the identity provider ask for", and answers it the same way for every provider. Like pkg/oidcauth it uses nothing beyond the standard library, so the parser an attacker's bytes reach first can be audited by reading it.

The providers this was written against

RFC 7644 leaves enough unsaid that the two providers that matter here speak noticeably different dialects of it, and both are accepted:

  • Microsoft Entra ID sends PATCH operations capitalised ("Replace"), booleans as the strings "True" and "False", group member removals as `{"op":"Remove","path":"members","value":[{"value":"…"}]}` — which by the letter of RFC 7644 would remove every member — and, with its SCIM 2.0 compliance flag, path-less operations whose value object's keys are themselves attribute paths (`"name.givenName"`). It creates groups under a schema URN of its own, replaces `emails[type eq "work"].value` on a user who has no work address, and its validator invents attributes and expects them back verbatim.
  • Okta deactivates with `{"op":"replace","value":{"active":false}}`, or on older integrations with a PUT of the whole user; renames a group with a replace whose value repeats the group's id; and sends a password, which is never stored.

Where the two disagree with the RFC, the reading chosen is the one that does what the provider meant, and the test corpus in pkg/ui replays both.

Index

Constants

View Source
const (
	DefaultPageSize = 100
	MaxPageSize     = 200
)

DefaultPageSize and MaxPageSize bound a list. The maximum is what the hub advertises in its ServiceProviderConfig.

View Source
const (
	SchemaUser           = "urn:ietf:params:scim:schemas:core:2.0:User"
	SchemaEnterpriseUser = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
	SchemaGroup          = "urn:ietf:params:scim:schemas:core:2.0:Group"

	SchemaListResponse          = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
	SchemaPatchOp               = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
	SchemaError                 = "urn:ietf:params:scim:api:messages:2.0:Error"
	SchemaServiceProviderConfig = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
	SchemaResourceType          = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
	SchemaSchema                = "urn:ietf:params:scim:schemas:core:2.0:Schema"

	// SchemaEntraGroup is the schema URN Microsoft Entra ID's provisioning
	// service attached to every group it created before its SCIM 2.0
	// compliance fixes. It names no attribute anybody defined, and is
	// accepted and dropped.
	SchemaEntraGroup = "http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/Group"
)

Schema and message URNs.

View Source
const (
	ErrInvalidFilter = "invalidFilter"
	ErrTooMany       = "tooMany"
	ErrUniqueness    = "uniqueness"
	ErrMutability    = "mutability"
	ErrInvalidSyntax = "invalidSyntax"
	ErrInvalidPath   = "invalidPath"
	ErrNoTarget      = "noTarget"
	ErrInvalidValue  = "invalidValue"
	ErrInvalidVers   = "invalidVers"
)

Error types (RFC 7644 §3.12, "scimType").

View Source
const DocumentationURI = "https://blechschmidt.github.io/cloop/docs/guides/scim-provisioning/"

DocumentationURI is where an operator reads how provisioning works on the hub.

View Source
const MediaType = "application/scim+json"

MediaType is SCIM's JSON media type. Responses carry it; requests may send it or application/json.

Variables

View Source
var EnterpriseUserSchema = Schema{
	ID: SchemaEnterpriseUser, Name: "EnterpriseUser", Description: "Enterprise attributes of a user.",
	Attributes: []Attribute{
		str("employeeNumber", "The user's number within the organisation."),
		str("costCenter", "The cost center."),
		str("organization", "The organisation."),
		str("division", "The division."),
		str("department", "The department."),
		{Name: "manager", Type: "complex", Mutability: "readWrite", Returned: "default", Uniqueness: "none",
			Description: "The user's manager.",
			SubAttributes: []Attribute{
				{Name: "value", Type: "string", Mutability: "readWrite", Returned: "default", Uniqueness: "none",
					CaseExact: true, Description: "The manager's id."},
				ref("$ref", "The manager's URI.", "User"),
				{Name: "displayName", Type: "string", Mutability: "readOnly", Returned: "default",
					Uniqueness: "none", Description: "The manager's display name."}}},
	},
}

EnterpriseUserSchema is the enterprise User extension.

View Source
var GroupSchema = Schema{
	ID: SchemaGroup, Name: "Group", Description: "A group provisioned by the identity provider. A role " +
		"binding naming it (claim: scim_group) grants its members a role on the hub.",
	Attributes: []Attribute{
		{Name: "displayName", Type: "string", Required: true, Mutability: "readWrite", Returned: "always",
			Uniqueness: "server", Description: "The group's name, unique on the hub, compared case-insensitively. " +
				"What a scim_group role binding names, or the externalId."},
		{Name: "members", Type: "complex", MultiValued: true, Mutability: "readWrite", Returned: "default",
			Uniqueness: "none", Description: "The group's members: provisioned users, by id.",
			SubAttributes: []Attribute{
				{Name: "value", Type: "string", Mutability: "immutable", Returned: "default", Uniqueness: "none",
					CaseExact: true, Description: "The member's id."},
				ref("$ref", "The member's URI.", "User"),
				{Name: "display", Type: "string", Mutability: "readOnly", Returned: "default",
					Uniqueness: "none", Description: "The member's display name."},
				{Name: "type", Type: "string", Mutability: "immutable", Returned: "default",
					Uniqueness: "none", Description: "User", CanonicalValues: []string{"User"}}}},
	},
}

GroupSchema is the core Group schema.

View Source
var UserSchema = Schema{
	ID: SchemaUser, Name: "User", Description: "A person provisioned by the identity provider.",
	Attributes: []Attribute{
		{Name: "userName", Type: "string", Required: true, Mutability: "readWrite", Returned: "always",
			Uniqueness: "server", Description: "The account's unique login, compared case-insensitively."},
		{Name: "name", Type: "complex", Mutability: "readWrite", Returned: "default", Uniqueness: "none",
			Description: "The components of the user's name.",
			SubAttributes: []Attribute{str("formatted", "The full name."), str("familyName", "The family name."),
				str("givenName", "The given name."), str("middleName", "The middle name."),
				str("honorificPrefix", "The honorific prefix."), str("honorificSuffix", "The honorific suffix.")}},
		str("displayName", "The name displayed to end users."),
		str("nickName", "The casual way to address the user."),
		ref("profileUrl", "A URI to the user's online profile.", "external"),
		str("title", "The user's title."),
		str("userType", "The relationship between the organisation and the user."),
		str("preferredLanguage", "The user's preferred written or spoken language."),
		str("locale", "The user's default location for localisation."),
		str("timezone", "The user's time zone."),
		{Name: "active", Type: "boolean", Mutability: "readWrite", Returned: "default", Uniqueness: "none",
			Description: "Whether the account may act. false suspends it on the hub: its sessions, API tokens, " +
				"glasses links, leases and running tasks end, and a deny binding refuses it everywhere."},
		{Name: "password", Type: "string", Mutability: "writeOnly", Returned: "never", Uniqueness: "none",
			Description: "Accepted and discarded: the hub authenticates people through single sign-on, " +
				"and never stores a password."},
		multi("emails", "Email addresses. The primary one, else the work one, is the account's email."),
		multi("phoneNumbers", "Phone numbers."),
		multi("ims", "Instant messaging addresses."),
		multi("photos", "URLs of photos of the user."),
		{Name: "addresses", Type: "complex", MultiValued: true, Mutability: "readWrite", Returned: "default",
			Uniqueness: "none", Description: "Physical mailing addresses.",
			SubAttributes: []Attribute{str("formatted", "The full address."), str("streetAddress", "The street."),
				str("locality", "The city or locality."), str("region", "The state or region."),
				str("postalCode", "The postal code."), str("country", "The country."),
				str("type", "A label indicating the address's function."),
				boolean("primary", "The primary address.")}},
		{Name: "groups", Type: "complex", MultiValued: true, Mutability: "readOnly", Returned: "default",
			Uniqueness: "none", Description: "The groups the user belongs to, maintained through the groups.",
			SubAttributes: []Attribute{
				{Name: "value", Type: "string", Mutability: "readOnly", Returned: "default", Uniqueness: "none",
					CaseExact: true, Description: "The group's id."},
				ref("$ref", "The group's URI.", "Group"),
				{Name: "display", Type: "string", Mutability: "readOnly", Returned: "default",
					Uniqueness: "none", Description: "The group's display name."},
				{Name: "type", Type: "string", Mutability: "readOnly", Returned: "default",
					Uniqueness: "none", Description: "direct", CanonicalValues: []string{"direct"}}}},
		multi("entitlements", "Entitlements."),
		multi("roles", "Roles. Stored and returned; they grant nothing on the hub — bind a provisioned group instead."),
		multi("x509Certificates", "Certificates."),
	},
}

UserSchema is the core User schema, as far as the hub reads and returns it.

Functions

func ApplyPatch

func ApplyPatch(obj map[string]any, kind Kind, id string, ops []PatchOp) error

ApplyPatch applies ops to obj, a normalised resource of kind whose id is id, in place. For a group, obj carries its members as {"value": id} entries and the caller reads them back with MemberIDs.

func Clone

func Clone(v any) any

Clone returns a deep copy of a JSON value.

func CloneObject

func CloneObject(obj map[string]any) map[string]any

CloneObject deep-copies a resource.

func Decode

func Decode(body []byte) (map[string]any, error)

Decode parses a request body into a JSON object.

func Describe

func Describe(v any) string

Describe renders a value for a message: short, and never a whole resource.

func ETag

func ETag(version int64) string

ETag renders a resource version the way meta.version and the ETag header carry it: a weak validator, since the representation is not byte-stable.

func FindByID

func FindByID(docs []map[string]any, id string) (map[string]any, bool)

FindByID returns the document whose id is id, case-insensitively.

func LooksLikeEmail

func LooksLikeEmail(s string) bool

LooksLikeEmail is the conservative shape check oidcauth applies before it lets preferred_username stand in for an email: one "@", something either side, a dot in the domain, no whitespace. The two must agree, or a directory matching on email would describe a user by an address the hub's sign-in never calls them.

func MatchesETag

func MatchesETag(header string, version int64) bool

MatchesETag reports whether an If-Match header value admits version. An empty header, and "*", admit any version. A list is accepted, weak or strong.

func MemberIDs

func MemberIDs(obj map[string]any) []string

MemberIDs reads a group's member ids.

func Normalize

func Normalize(obj map[string]any, kind Kind) (map[string]any, error)

Normalize returns the resource a provider sent as the hub stores it: known attribute names in their canonical spelling, booleans that arrived as "True" and "False" as booleans, and none of what a client may not write — id, meta and schemas, which the hub assigns; a user's password, which it never keeps; and a user's groups, which are read-only and follow from the groups' members.

A group's members stay in the object, normalised to {"value": id}; the caller moves them to the membership table.

func Paginate

func Paginate[T any](items []T, p Page) []T

Paginate returns the slice of items the page covers.

func Render

func Render(stored map[string]any, kind Kind, id string, meta Meta) map[string]any

Render is the representation a client receives: the stored object with the id, schemas and meta the hub assigns.

func ResourceTypes

func ResourceTypes(base string) []map[string]any

ResourceTypes are the /ResourceTypes documents, by id.

func Schemas

func Schemas(base string) []map[string]any

Schemas are the /Schemas documents.

func ServiceProviderConfig

func ServiceProviderConfig(base string) map[string]any

ServiceProviderConfig is the /ServiceProviderConfig document for a hub whose SCIM base URL is base.

func SetMembers

func SetMembers(obj map[string]any, ids []string)

SetMembers replaces a group object's members with ids.

func StatusFor

func StatusFor(method string, created bool) int

StatusFor names the HTTP status a request method succeeds with.

func WriteError

func WriteError(w http.ResponseWriter, err error)

WriteError writes err as a SCIM error response.

func WriteJSON

func WriteJSON(w http.ResponseWriter, status int, v any)

WriteJSON writes v with SCIM's media type.

Types

type AttrPath

type AttrPath struct {
	// URN is the extension schema the attribute belongs to, canonically
	// spelt, or "" for a core or common attribute.
	URN string
	// Name is the attribute's name as written.
	Name string
	// Sub is the sub-attribute's name, or "".
	Sub string
}

AttrPath names an attribute: optionally qualified by its schema URN, and optionally narrowed to one sub-attribute.

func ParseAttrPath

func ParseAttrPath(word string, kind Kind) (AttrPath, error)

ParseAttrPath parses an attribute path: [URN ":"] name ["." sub].

An extension URN the kind defines is recognised whether a ":" or a "." follows it: Microsoft Entra ID spelt `…:enterprise:2.0:User.employeeNumber` before its compliance fixes. The core schema's own URN is accepted as a redundant qualifier and dropped.

func (AttrPath) String

func (p AttrPath) String() string

type Attribute

type Attribute struct {
	Name            string      `json:"name"`
	Type            string      `json:"type"`
	MultiValued     bool        `json:"multiValued"`
	Description     string      `json:"description,omitempty"`
	Required        bool        `json:"required"`
	CaseExact       bool        `json:"caseExact"`
	Mutability      string      `json:"mutability"`
	Returned        string      `json:"returned"`
	Uniqueness      string      `json:"uniqueness"`
	CanonicalValues []string    `json:"canonicalValues,omitempty"`
	ReferenceTypes  []string    `json:"referenceTypes,omitempty"`
	SubAttributes   []Attribute `json:"subAttributes,omitempty"`
}

Attribute is one attribute definition (RFC 7643 §7).

type Email

type Email struct {
	Value   string
	Type    string
	Primary bool
}

Email is one entry of a user's emails.

type Error

type Error struct {
	Status int
	Type   string
	Detail string
}

Error is a SCIM error response: an HTTP status, the scimType RFC 7644 names for it when there is one, and a sentence for whoever reads the provisioning log. It is also the error every function in this package returns for input it refuses, so a caller can hand it to the client as it is.

func AsError

func AsError(err error) *Error

AsError returns err as a SCIM error, wrapping anything else in a 500 whose detail says nothing of the internals.

func BadRequest

func BadRequest(scimType, format string, args ...any) *Error

BadRequest is a 400 of the given type.

func Conflict

func Conflict(format string, args ...any) *Error

Conflict is the 409 uniqueness error.

func Errorf

func Errorf(status int, scimType, format string, args ...any) *Error

Errorf builds an Error.

func NotFound

func NotFound(kind, id string) *Error

NotFound is the 404 for a resource id that does not resolve.

func (*Error) Error

func (e *Error) Error() string

func (*Error) MarshalJSON

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

MarshalJSON renders the RFC 7644 §3.12 error document. status is a string there, not a number.

type Filter

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

Filter is a parsed filter expression.

func ParseFilter

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

ParseFilter parses a filter for resources of kind.

func (*Filter) Equality

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

Equality reports the filter's single top-level comparison when it is nothing but `attr eq "string"` on a core or common attribute with no sub-attribute — the shape a storage layer can answer from an index. ok is false for anything else, which the caller then evaluates with Matches.

func (*Filter) Matches

func (f *Filter) Matches(obj map[string]any) bool

Matches reports whether a resource satisfies the filter.

func (*Filter) String

func (f *Filter) String() string

String renders a filter back, for an error message or the audit trail.

type GroupFields

type GroupFields struct {
	DisplayName string
	ExternalID  string
	// Members are the member ids, deduplicated, in the order given.
	Members []string
}

GroupFields are the attributes of a group the hub decides on.

func ReadGroup

func ReadGroup(obj map[string]any) (GroupFields, error)

ReadGroup reads the fields the hub decides on out of a normalised group.

type Kind

type Kind int

Kind is a resource type the hub serves.

const (
	// KindUser is a provisioned user.
	KindUser Kind = iota
	// KindGroup is a provisioned group.
	KindGroup
)

func (Kind) String

func (k Kind) String() string

type ListResponse

type ListResponse struct {
	Schemas      []string         `json:"schemas"`
	TotalResults int              `json:"totalResults"`
	StartIndex   int              `json:"startIndex"`
	ItemsPerPage int              `json:"itemsPerPage"`
	Resources    []map[string]any `json:"Resources"`
}

ListResponse is the RFC 7644 §3.4.2 list document.

func NewListResponse

func NewListResponse(total int, page Page, resources []map[string]any) ListResponse

NewListResponse wraps one page of resources.

type Meta

type Meta struct {
	Created      time.Time
	LastModified time.Time
	Location     string
	Version      int64
}

Meta is a resource's meta attribute.

type Page

type Page struct {
	// StartIndex is 1-based.
	StartIndex int
	// Count is the most resources to return; 0 asks for the total alone.
	Count int
}

Page is a request's pagination.

func ParsePage

func ParsePage(q url.Values) (Page, error)

ParsePage reads ?startIndex= and ?count= (RFC 7644 §3.4.2.4): a startIndex below 1 is 1, a negative count is 0, and a count above MaxPageSize is MaxPageSize.

func (Page) Offset

func (p Page) Offset() int

Offset is the 0-based index of the first resource.

type PatchOp

type PatchOp struct {
	// Op is add, replace or remove, lowercased.
	Op string
	// Path is the target, or "" for a path-less operation.
	Path string
	// Value is the operation's value; HasValue says whether one was given.
	Value    any
	HasValue bool
}

PatchOp is one PATCH operation.

func ParsePatch

func ParsePatch(body []byte) ([]PatchOp, error)

ParsePatch parses a PATCH request body. Member names are matched case-insensitively ("Operations", "operations"), and so is the op.

type Projection

type Projection struct {
	Attributes []AttrPath
	Excluded   []AttrPath
}

Projection is a request's attributes and excludedAttributes parameters.

func ParseProjection

func ParseProjection(q url.Values, kind Kind) (Projection, error)

ParseProjection reads ?attributes= and ?excludedAttributes=.

func (Projection) Apply

func (p Projection) Apply(res map[string]any, kind Kind) map[string]any

Apply narrows a rendered resource to the projection.

type Schema

type Schema struct {
	ID          string      `json:"id"`
	Name        string      `json:"name"`
	Description string      `json:"description"`
	Attributes  []Attribute `json:"attributes"`
}

Schema is one schema definition.

type UserFields

type UserFields struct {
	UserName    string
	ExternalID  string
	DisplayName string
	// Active is nil when the resource does not say. A replace without it
	// leaves the account as it was: an identity provider that omits the
	// attribute has not asked for a reactivation, and treating its absence
	// as true would lift a suspension nobody lifted.
	Active *bool
	// Email is the account's address, lowercased: the primary email, else the
	// work address, else the first, else userName when it is address-shaped.
	Email  string
	Emails []Email
}

UserFields are the attributes of a user the hub decides on.

func ReadUser

func ReadUser(obj map[string]any) (UserFields, error)

ReadUser reads the fields the hub decides on out of a normalised user.

Jump to

Keyboard shortcuts

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