Help us shape the future of Go! Take the 2026 Go Developer Survey

directory

package
v0.45.0 Latest Latest
Warning

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

Go to latest
Published: Oct 30, 2026 License: Apache-2.0, Apache-2.0 Imports: 11 Imported by: 0

README

Directory facts and SCIM groups

The directory maps its source data into Writ's user and membership objects. User and Group describe authorization facts. ManagedUser and ManagedGroup describe the control-plane records used by storage and SCIM. User.Facts maps the directory's data-plane types into Writ objects. Users need an authentication ID. On SCIM creation, Epithet selects a configured attribute and assigns its value to the user's id before calling storage. That same ID addresses the SCIM resource and the authentication lookup. Storage preserves the supplied ID and does not interpret externalId or OIDC claims. Inactive users are absent from the authorization lookup. Administration can still inspect inactive users.

Each group membership contains the group's current display name and SCIM resource ID. An optional group externalId is exposed as an additional attribute. Group names need not be unique. group:ops matches members of any current group named ops; group{id:...} follows one resource across renames. Once a rename is visible, its new name replaces the old name. Deleting and recreating a group under the same name makes name-based rules follow the replacement; ID-based rules do not follow it.

There are no historical group-name bindings. epithet directory groups list lists current names and resource IDs through paginated SCIM; epithet directory groups audit retains provisioning history and historical binding events. The bind command is removed. Existing administrator group grants also match current names.

Static YAML supplies structured memberships without requiring group IDs:

users:
  - id: oidc-subject
    userName: alice
    name: [alice, alice@example.com]
    attributes:
      department: engineering
      role: [developer, oncall]
    groups:
      - name: [operators]
        attributes:
          environment: production

Omitting name uses userName as the selectable name when present. An explicit empty name collection supplies no selectable names. userName remains certificate presentation, with ID fallback. Additional attributes must be strings, collections of strings, or null, and cannot replace the defined name or id fields.

See the fact API contract for the HTTP shape and Writ specification for matching rules.

SCIM identity configuration

Set scim-user-id-attribute in TOML or use epithet directory --scim-user-id-attribute ATTRIBUTE. The default is externalId. The setting is read by the directory process, including when launched by epithet server with the shared configuration file. It does not change the OIDC claim selection; configure oidc-user-id-claim so its value matches the selected SCIM attribute.

For example, an Entra integration can send objectId as SCIM externalId and use:

scim-user-id-attribute = "externalId"
oidc-user-id-claim = "oid"

The adapter supports the string attributes it stores: externalId, userName, userType, urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department, and urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization. Attribute names are case-insensitive; identity values are exact. Unsupported attributes, including the incoming read-only id, are configuration errors. A missing or blank selected value fails creation; there is no fallback to another attribute. This does not add arbitrary extension-attribute storage.

Choose a value that is unique, stable and never reassigned to another resource. The configured attribute is used only at creation: subsequent operations use the URL ID, which remains unchanged even if the source attribute is replaced or omitted. Changing this setting does not remap existing users. The store treats externalId as optional data without a uniqueness constraint.

Go service implementors configure the adapter with factserver.WithSCIMUserIDAttribute(attribute) alongside factserver.WithDirectoryManagement(store). Direct Store.CreateUser callers must supply the resolved ManagedUser.ID themselves. Storage returns the updated object with that ID and the caller-supplied Created and Modified timestamps. Storage assigns Version. The SCIM adapter sets both timestamps on creation and Modified on replacement. Storage retains the original Created on replacement. DeleteUser takes an explicit modification time for the groups whose memberships it removes. Replacement selects the resource by the supplied object's ID, with no separate ID argument. The SCIM adapter sets that ID from the request URL.

Epithet generates group IDs before calling storage. Direct Store.CreateGroup callers supply their own ID; storage preserves it and rejects collisions with existing user or group IDs.

Before a direct store write, call user.Validate() or group.Validate() and handle its error. Validation rejects blank required fields without changing the record. The SCIM adapter validates automatically before writes. Storage receives the actual attributes and enforces ID uniqueness, username uniqueness, membership references and existence. Username uniqueness uses Unicode NFKC normalization followed by case folding, while preserving the supplied spelling. Derived comparison keys and indexes are private to each backend; callers do not supply or maintain them.

Callers also deduplicate ManagedGroup.MemberIDs before creation or replacement. The SCIM adapter does this when converting the request into a managed group; storage implementations need not normalize duplicate input.

Store.SupportsPatchUser() and Store.SupportsPatchGroup() declare partial-update support separately for users and groups. These are stable backend capabilities, not per-request authorization checks. SCIM advertises patch.supported when either is true and returns a SCIM 501 Not Implemented error for PATCH requests against an unsupported resource type. Both current backends return false; the patch operations themselves are not implemented yet.

LookupUser returns only the user and an error; directory implementations do not need a global revision. Audit events also have no directory revision. Epithet constructs each event before calling a mutation, supplying its actor, action, resource ID and timestamp. Storage preserves the event unchanged. SQLite writes the event in the same transaction as the mutation; other backends retain their own consistency semantics. Audit history survives resource deletion.

Audit(ctx, cursor, limit) returns events and an opaque continuation cursor. Empty cursor starts a read; pass the returned cursor back unchanged. Empty next means no further page. Ordering, visibility during writes, and cursor lifetime belong to the backend. Timestamps need not be unique or monotonic. HTTP and CLI pages contain events and an optional next; use --cursor to continue.

Testing a directory backend

Use directory/storetest.Run to check an implementation of Directory and Store without starting an HTTP server. In your backend's test package:

func TestStoreContract(t *testing.T) {
    storetest.Run(t, func(t *testing.T) storetest.Backend {
        store := newMemoryDirectory() // Your constructor; initialize fresh state.
        backend := storetest.Backend{Directory: store, Store: store}
        return backend
    })
}

Import testing and github.com/epithet-ssh/epithet/pkg/facts/directory/storetest. The factory runs for each subtest and must return a fresh, empty directory. These sequential examples expect completed writes to be readable in the test fixture. For a backend with delayed visibility, the factory can supply a test view that waits for its own propagation. This fixture assumption is not a consistency requirement for the storage interface. Register cleanup with t.Cleanup; see SQLite's factory.

The directory interfaces describe complete operations without requiring transactions, concurrent access, immediate read-after-write visibility, or coherent snapshots across resources, memberships and audit history. Those properties belong to each backend. A failed call does not establish whether a write took effect.

Resource versioning is optional. SupportsVersioning() declares support for users and groups. Providers that return false leave Metadata.Version empty; callers pass an empty expectedVersion. Providers that return true assign nonempty opaque versions and change them on mutations, including membership cleanup. The in-memory example supports versioning.

SCIM advertises this capability as etag.supported. With versioning, the adapter returns the token as both an ETag header and meta.version, and translates a client's If-Match header into expectedVersion. Without versioning, both response fields are omitted. A specific ETag cannot match an unversioned resource and receives 412; requests without a version condition remain available. Replacement metadata does not supply the version condition.

SQLite supports versioning and compares versions atomically with writes. Its transactional mutations and coherent reads are SQLite properties, tested in sqlitestore, rather than requirements for other backends.

Run go test -v ./path/to/your/backend. The shared suite exercises sequential user and group lifecycle, authorization projections, pagination and audit. It does not establish transactionality, concurrency safety, or propagation timing. Test any stronger backend guarantees separately, along with persistence, failure handling and HTTP behavior.

Database upgrade

The application creates new databases directly at schema version 4, without an aliases table or uniqueness constraint on user externalId. Audit rows contain timestamps and event fields, without sequence or revision columns. It opens version 4 databases normally and rejects other schema versions without upgrading them. PRAGMA user_version identifies the schema the binary understands; the application does not manage migrations.

Version 2 stored separately generated user IDs and looked up users by externalId. Simply changing its version marker would not convert those identities or their membership references. This change supplies no database conversion tool; an existing deployment needs a separately planned conversion.

A supported migration framework, potentially Goose, is deferred in YATL gt3vjk35. No framework or automatic upgrade path is introduced by this change.

Documentation

Overview

Package directory owns managed identities, administration, and the user and group facts supplied to authorization.

Index

Constants

View Source
const DefaultAuditLimit = 100
View Source
const MaxAuditLimit = 1000

Variables

View Source
var (
	ErrInvalid  = errors.New("invalid directory resource")
	ErrNotFound = errors.New("directory resource not found")
	ErrConflict = errors.New("directory uniqueness conflict")
	ErrVersion  = errors.New("directory revision changed")
)

Functions

func Actor added in v0.43.0

func Actor(ctx context.Context) string

Actor returns the trusted actor, retaining "scim" for direct provisioning callers without a human identity (including imports and shared-secret calls).

func WithActor added in v0.43.0

func WithActor(ctx context.Context, actor string) context.Context

WithActor attaches the identity authenticated by the service boundary. HTTP payloads must never supply it. NewAuditEvent reads it before calling storage.

Types

type AuditEvent

type AuditEvent struct {
	Time       time.Time
	Actor      string
	Action     string
	ID         string
	Alias      string
	PreviousID string
}

AuditEvent describes a mutation. Callers supply the event alongside the write; storage preserves all of its fields.

func NewAuditEvent added in v0.43.0

func NewAuditEvent(ctx context.Context, action, id string) AuditEvent

NewAuditEvent constructs an event above storage using the authenticated actor. The caller supplies the action and resource ID; the timestamp records this operation.

type Directory

type Directory interface {
	LookupUser(context.Context, string) (*User, error)
}

Directory resolves users within one configured identity provider. LookupUser returns user facts. Missing users return nil; storage failure is an error, never a missing user. Read consistency and visibility of user and membership changes are backend-specific. The lookup key and returned User.ID are the stored user's ID. Mapping an OIDC claim or provisioning attribute to that ID is Epithet's responsibility.

type Group

type Group struct {
	ID         string
	Names      []string
	Attributes object.Attributes
}

Group contains the group facts supplied to authorization. Names are aliases for one group; ID is optional for sources without stable group identities. ManagedGroup holds the provisioning record, including its member IDs.

type ManagedGroup added in v0.43.0

type ManagedGroup struct {
	Metadata
	ExternalID  string
	DisplayName string
	MemberIDs   []string // Distinct stored user IDs, not provider ExternalIDs; callers deduplicate before writing.
}

ManagedGroup owns direct user memberships. DisplayName changes affect name selectors; the stable resource ID continues to select the same group.

func (ManagedGroup) Group added in v0.43.0

func (g ManagedGroup) Group() Group

Group returns the authorization facts for this managed group. DisplayName becomes its name and a nonempty ExternalID becomes the externalId attribute. MemberIDs, Version, Created and Modified are omitted from the result.

func (ManagedGroup) Validate added in v0.43.0

func (g ManagedGroup) Validate() error

Validate checks input-only group constraints before creation or replacement, without changing the record. ID must already be assigned by the caller. Storage handles checks that depend on existing records.

type ManagedUser

type ManagedUser struct {
	Metadata
	ExternalID   string
	UserName     string
	Active       bool
	UserType     string
	Department   string
	Organization string
}

ManagedUser contains the identity and attributes used by authorization. Active is explicit at the storage boundary; the HTTP adapter supplies the SCIM default. Group membership is owned by ManagedGroup, not by the user.

func (ManagedUser) Validate added in v0.43.0

func (u ManagedUser) Validate() error

Validate checks input-only user constraints before creation or replacement, without changing the record. ID must already be assigned by the caller. Storage owns uniqueness, existence and version checks against current state.

type Metadata

type Metadata struct {
	ID       string
	Version  string
	Created  time.Time
	Modified time.Time
}

Metadata describes a provisioned user or group. User IDs are supplied by Epithet after identity mapping; Epithet generates group IDs before storage. The caller supplies Created and Modified. Storage retains Created on replacement, preserves the supplied Modified, and owns Version for both resource types. Version is empty when Store.SupportsVersioning is false. Otherwise it is a nonempty opaque token, compared only for equality, that changes on mutations including membership cleanup after user deletion.

type Static

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

Static owns directory facts loaded from YAML at startup. Host and realm sections in combined configuration files are accepted but supply no facts. Reloading requires constructing a new directory.

func NewStatic

func NewStatic(paths []string) (*Static, error)

NewStatic concatenates the user records in paths. IDs are required; duplicate IDs or nonempty user names and unknown fields are load errors. An omitted active field defaults to true. The caller retains ownership of the files.

func (*Static) LookupUser

func (s *Static) LookupUser(_ context.Context, id string) (*User, error)

LookupUser implements Directory.

type Store

type Store interface {
	// SupportsVersioning declares whether users and groups have resource versions
	// usable for conditional writes. False means empty Metadata.Version and
	// callers pass empty expectedVersion. This capability is stable for the store's
	// lifetime; SCIM uses it to advertise ETag support. Backend consistency and
	// atomicity remain implementation properties.
	SupportsVersioning() bool

	// SupportsPatchUser and SupportsPatchGroup declare support for partial updates
	// to each resource type, including add, remove and replace operations. They
	// describe backend capabilities, not authorization for an individual request.
	// Values must remain stable for the lifetime of the store. The SCIM adapter
	// uses them for discovery and rejects PATCH for an unsupported resource type.
	SupportsPatchUser() bool
	SupportsPatchGroup() bool

	// CreateUser creates and returns a user with the supplied ID, preserving the
	// supplied Created and Modified timestamps. Version follows SupportsVersioning.
	// Epithet resolves identity before calling storage;
	// the store does not interpret SCIM attributes or OIDC claims.
	// IDs are unique by exact value. User names are unique under Unicode NFKC
	// normalization followed by case folding. Collisions return
	// ErrConflict, including collisions with inactive users. Preserve supplied
	// spelling and Active (there is no default at this boundary). ExternalID is
	// optional and need not be unique. A duplicate ID must never replace a user.
	// The new user has no memberships until a group adds its resource ID.
	CreateUser(context.Context, ManagedUser, AuditEvent) (ManagedUser, error)

	// ReplaceUser fully replaces the user selected by user.ID and returns
	// the updated user. Apply CreateUser's uniqueness rules,
	// excluding this user's existing values; omitted attributes become zero values.
	// ExternalID may change without changing the resource ID or group memberships.
	// ID selects the record; Modified is preserved. Supplied Created and Version
	// are ignored; retain the stored Created. Version follows SupportsVersioning.
	// Return ErrNotFound if absent or ErrConflict for a uniqueness violation.
	// Preserve memberships. Version handling follows the backend's semantics.
	ReplaceUser(ctx context.Context, user ManagedUser, expectedVersion string, event AuditEvent) (ManagedUser, error)

	// DeleteUser deletes the user and removes it from every group.
	// Each affected group gets the supplied modified timestamp and, when versioning
	// is supported, a new Version; group IDs and other attributes are retained. Release
	// the user's unique UserName. Return ErrNotFound if absent.
	// Version handling and visibility of membership cleanup follow the backend's
	// semantics. Persist the supplied audit event.
	DeleteUser(ctx context.Context, id, expectedVersion string, modified time.Time, event AuditEvent) error

	// GetUser returns the provisioning record, including metadata and inactive
	// users, selected by resource ID. Return nil, nil if absent. No state changes;
	// Directory.LookupUser uses the same ID to return facts.
	GetUser(context.Context, string) (*ManagedUser, error)

	// ListUsers returns a page of provisioning records, including inactive users,
	// in backend-defined order, plus the total user count before pagination. start
	// is one-based and must be at least 1; count must be nonnegative. Zero count
	// returns an empty page and the total, not a default page size. Invalid paging
	// arguments return an error. A start beyond the end returns an empty page.
	// Empty pages may be nil. Read consistency is backend-specific.
	ListUsers(ctx context.Context, start, count int) ([]ManagedUser, int, error)

	// CreateGroup creates and returns a group, preserving supplied ID, Created and
	// Modified. Version follows SupportsVersioning. An ID collision with any existing
	// user or group returns ErrConflict; creation never replaces a resource.
	// Preserve DisplayName spelling. DisplayName and ExternalID need not be unique;
	// ExternalID is optional. MemberIDs must reference existing user resource IDs (inactive
	// users are allowed); missing users or group references return ErrInvalid.
	// Callers deduplicate MemberIDs before writing; storage need not normalize
	// duplicate input. MemberIDs order is unspecified.
	// Nil and empty MemberIDs both mean no members.
	CreateGroup(context.Context, ManagedGroup, AuditEvent) (ManagedGroup, error)

	// ReplaceGroup fully replaces the group selected by group.ID, including its
	// complete membership set, and returns the updated group. Apply CreateGroup's
	// membership checks; nil/empty MemberIDs remove all members.
	// ID selects the record; Modified is preserved. Supplied Created and Version
	// are ignored; retain the stored Created. Version follows SupportsVersioning.
	// A rename changes group-name authorization facts without changing the resource
	// ID. Return ErrNotFound if absent or ErrInvalid for missing members.
	// Version handling follows the backend's semantics.
	ReplaceGroup(ctx context.Context, group ManagedGroup, expectedVersion string, event AuditEvent) (ManagedGroup, error)

	// DeleteGroup deletes the group and its memberships, leaving member users
	// intact. The group is removed from their authorization facts according to the
	// backend's propagation semantics. Return ErrNotFound if absent.
	// Persist the supplied audit event. Version handling is backend-specific.
	DeleteGroup(ctx context.Context, id, expectedVersion string, event AuditEvent) error

	// GetGroup returns the group selected by resource ID, with metadata and its
	// complete, distinct MemberIDs in unspecified order. An empty group's
	// membership slice may be nil. Return nil, nil if absent. Reading
	// the group does not change state.
	GetGroup(context.Context, string) (*ManagedGroup, error)

	// ListGroups returns a page of complete groups in backend-defined order, plus
	// the total group count before pagination. start is one-based and must be at
	// least 1; count must be nonnegative. Zero count returns an empty page and the
	// total, not a default page size. Invalid paging arguments return an error;
	// a start beyond the end returns an empty page, which may be nil. Memberships follow
	// GetGroup's semantics. Read consistency is backend-specific.
	ListGroups(ctx context.Context, start, count int) ([]ManagedGroup, int, error)

	// Audit returns at most limit events and a backend-owned continuation cursor.
	// Empty cursor starts a read; pass next back unchanged to continue. Empty next
	// means no further page is available. Cursors are scoped to this backend.
	// Ordering, consistency during writes, and cursor lifetime are backend
	// properties; timestamps need not be unique or monotonic.
	// Zero limit uses DefaultAuditLimit; negative limits or limits above
	// MaxAuditLimit return ErrInvalid. Empty pages may be nil.
	// Events retain their supplied timestamps and survive resource deletion.
	Audit(ctx context.Context, cursor string, limit int) (events []AuditEvent, next string, err error)
}

Store is the directory management contract, independent of Directory fact lookup. Each method describes a complete directory operation. Implementations own persistence and authorization indexes. Consistency, concurrent access, propagation timing and failure atomicity are backend properties, not guarantees of this interface. An error does not establish whether a write took effect.

Callers validate users and groups before writing; stores may assume valid input attributes. Indexes and derived storage values stay private to backends. Creation preserves the supplied ID for users and groups. IDs are unique across both resource types; a collision returns ErrConflict without replacing anything. Creation preserves supplied Created and Modified (including zero). Storage ignores caller-supplied Version: it assigns a token when versioning is supported, or leaves Version empty otherwise. Replacement selects the record by its ID, retains stored Created, preserves supplied Modified, and follows the same versioning capability, changing the token when supported. Each mutation receives an audit event constructed by the caller. Storage persists the supplied event with the mutation; it does not derive the event's actor, action, ID or time. Audit history survives resource deletion. Backend failures remain errors, never absent resources.

Replace/Delete receive expectedVersion from the caller; enforcement is backend-specific. Callers pass empty when SupportsVersioning is false. Empty means no requested version check. The SCIM adapter interprets HTTP conditional headers. Replacement never creates a resource.

Contexts bound reads and mutations. The caller owns implementation lifetime; closing it is not a management operation.

type User

type User struct {
	UserName     string
	ID           string
	Active       bool
	Groups       []Group
	Names        []string
	Attributes   object.Attributes
	UserType     string
	Department   string
	Organization string
}

func (User) Facts added in v0.42.0

func (u User) Facts() object.User

Facts projects this built-in directory record onto the provider contract. The source schema owns its field mapping; the matcher does not know SCIM.

Directories

Path Synopsis
Package scim adapts SCIM provisioning to the directory storage contract.
Package scim adapts SCIM provisioning to the directory storage contract.
Package sqlitestore implements the managed directory using embedded SQLite.
Package sqlitestore implements the managed directory using embedded SQLite.
Package storetest tests the public directory storage and lookup contracts.
Package storetest tests the public directory storage and lookup contracts.

Jump to

Keyboard shortcuts

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