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
- Variables
- func ApplyPatch(obj map[string]any, kind Kind, id string, ops []PatchOp) error
- func Clone(v any) any
- func CloneObject(obj map[string]any) map[string]any
- func Decode(body []byte) (map[string]any, error)
- func Describe(v any) string
- func ETag(version int64) string
- func FindByID(docs []map[string]any, id string) (map[string]any, bool)
- func LooksLikeEmail(s string) bool
- func MatchesETag(header string, version int64) bool
- func MemberIDs(obj map[string]any) []string
- func Normalize(obj map[string]any, kind Kind) (map[string]any, error)
- func Paginate[T any](items []T, p Page) []T
- func Render(stored map[string]any, kind Kind, id string, meta Meta) map[string]any
- func ResourceTypes(base string) []map[string]any
- func Schemas(base string) []map[string]any
- func ServiceProviderConfig(base string) map[string]any
- func SetMembers(obj map[string]any, ids []string)
- func StatusFor(method string, created bool) int
- func WriteError(w http.ResponseWriter, err error)
- func WriteJSON(w http.ResponseWriter, status int, v any)
- type AttrPath
- type Attribute
- type Email
- type Error
- type Filter
- type GroupFields
- type Kind
- type ListResponse
- type Meta
- type Page
- type PatchOp
- type Projection
- type Schema
- type UserFields
Constants ¶
const ( DefaultPageSize = 100 MaxPageSize = 200 )
DefaultPageSize and MaxPageSize bound a list. The maximum is what the hub advertises in its ServiceProviderConfig.
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.
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").
const DocumentationURI = "https://blechschmidt.github.io/cloop/docs/guides/scim-provisioning/"
DocumentationURI is where an operator reads how provisioning works on the hub.
const MediaType = "application/scim+json"
MediaType is SCIM's JSON media type. Responses carry it; requests may send it or application/json.
Variables ¶
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.
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.
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 ¶
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 CloneObject ¶
CloneObject deep-copies a resource.
func ETag ¶
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 LooksLikeEmail ¶
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 ¶
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 Normalize ¶
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 Render ¶
Render is the representation a client receives: the stored object with the id, schemas and meta the hub assigns.
func ResourceTypes ¶
ResourceTypes are the /ResourceTypes documents, by id.
func ServiceProviderConfig ¶
ServiceProviderConfig is the /ServiceProviderConfig document for a hub whose SCIM base URL is base.
func SetMembers ¶
SetMembers replaces a group object's members with ids.
func WriteError ¶
func WriteError(w http.ResponseWriter, err error)
WriteError writes err as a SCIM error response.
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 ¶
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.
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 Error ¶
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 ¶
AsError returns err as a SCIM error, wrapping anything else in a 500 whose detail says nothing of the internals.
func BadRequest ¶
BadRequest is a 400 of the given type.
type Filter ¶
type Filter struct {
// contains filtered or unexported fields
}
Filter is a parsed filter expression.
func ParseFilter ¶
ParseFilter parses a filter for resources of kind.
func (*Filter) Equality ¶
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.
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.
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 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.
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 ¶
ParsePatch parses a PATCH request body. Member names are matched case-insensitively ("Operations", "operations"), and so is the op.
type Projection ¶
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=.
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.