M1-31: People, owners and own profile¶
Status: ready | Depends on: M1-20, M1-29 | Milestone: M1
Goal¶
After this ticket an owner can create people, change their profile, activate pending or disabled people, list and read the directory, appoint further owners and remove owners, and every person can read their own principal and maintain their own profile. There is no self registration. Owner and status changes are permission changes: they run through the hook of M1-29, so I1 and SR8 hold for every sequence of these actions, and a second owner is impossible while an agent runs on a personal subscription. Ten REST operations become reachable.
Read first¶
docs/PLAN.md: section 3, section 7 (M1), rows M1-26, M1-29, M1-31, M1-37, M1-45, M1-46.docs/spec/05-actions.md: 2.3 (owner only in a session), 2.4, 2.5, table 4.2 (owner.add,owner.remove), table 4.3 (self.read,user.read,user.create,user.update,user.enable,self.profile.update), 6 (user.updated), 8 (R-AZ-03, R-AZ-14).docs/spec/07-permissions.md: 2.7 (Owner), 3 (Principal context), 4.1, 4.2 step 8 (step-up), 5 (SR1, SR5, SR7, SR8), 11.1 (AZ-M23, AZ-M26, AZ-M35, AZ-M36).docs/spec/08-identity.md: 3.1 (provisioning aspending), 3.2 (Enrollment code), 4 (Initial installation), 11 (Disabling a person, for contrast), 18.docs/spec/03-data-model.md: 1 (principles 6, 7, 13), 4.2principals,users,owners, 9 (principal.handle_max_chars).docs/spec/10-backend-policy.md: 3 (consequence of I1 for owners), 5.1 (rowowner.add,owner.remove,user.enable), 5.4.docs/spec/06-api.md: 2 (Basic rules), 3.3 (Step-up), 4, 5 (Errors), 6 (Pagination), 7 (Idempotency), 16 (Me, Users).docs/spec/19-security.md: SEC-IN-1, SEC-IN-2, SEC-IN-4, SEC-LOG-4, SEC-T07.docs/api/openapi.yaml: operationslistUsers,createUser,getUser,updateUser,enableUser,listOwners,addOwner,revokeOwner,getMe,updateMyProfile; schemasUser,UserCreate,UserCreated,UserUpdate,UserPage,UserStatus,ProfileUpdate,Me,PrincipalRef,Owner,OwnerAdd,OwnerList,ReasonBody,Problem,InvariantViolation,UserUpdatedEvent,UserPayload.- ADRs: 0015, 0017, 0019, 0020, 0021, 0053.
- Merged code:
internal/identitywithprincipals,users,owners,DirectoryandPrincipalSource(M1-26); the permission-change hook,InvariantErrorwithconflictsand the problem extension (M1-29);authorizeand the source ports (M1-28); the HTTP chain, problem writer, test authenticator if any (M1-18); idempotency middleware (M1-20); the generated strict server andservices/server/api/operations.yaml(M1-17). Their names win where they differ from this brief only in naming. docs/quality/gates.md,CONTRIBUTING.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: red before green, red output kept for the report.
- No en dash or em dash in any file; White Label (Alex, Sam, Robin,
example.com). - Configuration values only through named keys listed in
docs/operations/configuration.md; this ticket adds none. - No self registration: people are created only by an owner (
user.create) or, later, by provisioning and bootstrap (08 sections 3.1 and 4). user.create,user.update,user.enable,owner.add,owner.removeare owner actions with step-up and audit (05 tables 4.2 and 4.3); owner permissions only in a session, never through a personal token (SR5, R-AZ-14).owner.add,owner.remove,user.enableare permission changes (column Permissions = yes):permission_version,access.changed, I1 and SR8 in the same transaction (05 rules 1.6 and 1.7).owner.addwhile an agent hasbilling_mode = 'personal'fails with409 i1_violation(07 SR1 consequence, AZ-M23); the last active owner stays (409 last_owner, SR8).user.enablecoverspendingtoactiveanddisabledtoactive(05 table 4.3; 08 section 3.1).- Display texts with control or bidi characters are rejected (SEC-IN-4, SEC-T07); handles follow 03
principals. - No JSON
nullin responses and events (ADR 0053); every response matches its schema.
Scope¶
1. Port (root package internal/identity)¶
Extend the ports from M1-26 with DTOs (no domain types in the port):
Directory:ListUsers(ctx, actor, ListUsersQuery{Q, Status, Cursor, Limit}) (UserPage, error),GetUser(ctx, actor, userID) (User, error),CreateUser(ctx, actor, CreateUserCmd) (User, error),UpdateUser(ctx, actor, userID, UpdateUserCmd) (User, error),EnableUser(ctx, actor, userID, reason) (User, error),GetMe(ctx, actor) (Me, error),UpdateMyProfile(ctx, actor, ProfileCmd) (User, error).Owners:ListOwners(ctx, actor) ([]OwnerEntry, error),AddOwner(ctx, actor, userID, reason) (OwnerEntry, error),RevokeOwner(ctx, actor, userID, reason) error.
2. Use cases (internal/identity/app, files people.go, owners.go, me.go)¶
Each use case loads the resource, calls authorize, runs in one unit of work for writes, writes the audit entry through auditing.Recorder where column Audit = yes, and appends user.updated where listed.
| Operation | Use case | Action and resource | Permission change | Event |
|---|---|---|---|---|
listUsers |
ListUsers |
user.read on instance |
no | none |
createUser |
CreateUser |
user.create on instance |
no | user.updated |
getUser |
GetUser |
user.read on the user |
no | none |
updateUser |
UpdateUser |
user.update on the user |
no | user.updated |
enableUser |
EnableUser |
user.enable on the user |
yes, affected = the user | user.updated |
listOwners |
ListOwners |
user.read on instance |
no | none |
addOwner |
AddOwner |
owner.add on the user |
yes, affected = the user | user.updated |
revokeOwner |
RevokeOwner |
owner.remove on the user |
yes, affected = the user | user.updated |
getMe |
GetMe |
self.read on the caller |
no | none |
updateMyProfile |
UpdateMyProfile |
self.profile.update on the caller |
no | user.updated |
Rules per use case:
CreateUser: new principal (UUIDv7,kind = 'user',status = 'active') andusersrow.handlematches^[a-z0-9][a-z0-9._-]*$, at mostprincipal.handle_max_chars, unique ignoring case (409 conflicton collision).display_namenot blank, at mostprincipal.display_name_max_charsrunes and free of control and bidi characters (reuse the validation of M1-26; 03principals.display_name).emailoptional, unique ignoring case (409 conflict).localeoptional, a well-formed BCP 47 tag parsed withgolang.org/x/text/languageand stored in canonical form; when absent the column default of 03 applies.time_zoneoptional, an IANA name accepted bytime.LoadLocation, neverLocalor empty; the identity domain importstime/tzdatabecause the server image has no zoneinfo.local_account: trueis answered with400 validation_failed, pointer/local_account, until M1-37 adds enrollment codes (record this interim rule indocs/DECISIONS.mdwith the note that M1-37 replaces it). Response201 UserCreatedwithoutenrollment.UpdateUser: fields ofUserUpdate, same validation; absent fields stay; the handle never changes.EnableUser:pendingordisabledtoactive,disabled_atcleared;deletedis409 invalid_state_transition; an already active person returns200with the unchanged user and an allow audit entry, but no permission change and no event. Enabling never restores devices or sessions revoked byuser.disable(08 section 11).AddOwner: the user must be an active principal of kinduser(404if no such user,409 conflictif not active or already an owner); insert intoowners(granted_by= caller,reason); declare the permission change; I1 and SR8 are verified by the hook, which answers409 i1_violationwithconflicts. Response201 Owner.RevokeOwner:404if the user has no active owner row; setrevoked_at,revoked_by; declare the permission change;409 last_ownerfrom the hook if no active owner remains, also when owners remove themselves. Response204.GetMe: for humans, agents and apps (an agent and an app read themselves).principalfromprincipals;is_ownertrue only for a principal with an active owner row;email,locale,time_zoneonly for humans;permission_version= the version the request was authorized with;session_idanddevice_idfrom the actor when present;step_up_valid_until=step_up_atplusauth.step_up_max_ageonly when that lies in the future. Allowed for a pending device (05 section 2.5).UpdateMyProfile:display_name,locale,time_zoneof the calling human, same validation; no step-up, no audit (05 table 4.3).ListUsers: every status unlessstatusis given;qis a case-insensitive prefix match on handle or display name (escape%and_); keyset order(lower(handle), id)ascending;limitdefaults toapi.page_size_default, larger values are capped atapi.page_size_max; opaque cursor bound to the filters, a foreign or stale cursor is400 cursor_invalid; no totals (06 section 6). Use the cursor helper of the merged tree if one exists.ListOwners: active owners ordered bygranted_at, unpaginated (06 section 6 point 6).GetUser: an ID that is not a human (agent, app, unknown) is404.
3. Persistence (internal/identity/adapters/postgres)¶
sqlc queries in queries/people.sql and queries/owners.sql; no other module reads these tables (03 principle 12). Owner rows locked in ascending id.
4. HTTP (internal/identity/adapters/httpapi)¶
Implement the ten methods of the generated strict server interface; map DTOs to the generated types; no logic beyond mapping. Move the ten operation IDs from the section later into the built section of services/server/api/operations.yaml (P-04), so that the route test of AZ-P1 counts them.
5. Event user.updated¶
Envelope per 04 section 2.2: resource = {kind: user, id} (no version, users has none), empty scope, actor from the request, payload UserPayload{user: User} with the state after the change, including is_owner. Receive action user.read (05 section 6).
6. Audit details¶
IDs and field names only, never values of names, e-mail addresses or locales (SEC-LOG-4; 03 audit_events.details): user.create {}, user.update {changed_fields}, user.enable {from_status, reason}, owner.add {reason}, owner.remove {reason}.
7. Documentation¶
services/server/internal/identity/README.md (operations, use cases, events, rules), docs/DECISIONS.md (rulings below and the interim rule for local_account), services/server/CHANGELOG.md under Unreleased.
Out of scope¶
disableUserand the revocation of devices, sessions and tokens: M1-45.- Enrollment codes,
local_account,createEnrollmentCode: M1-37. - Applying the instance setting
locale.defaultto new people: M1-46 wires it; until then the column default of 03 (de), which equals the documented default oflocale.default, applies. - Avatars (
setMyAvatar,deleteMyAvatar): M3. - Identities, passkeys and credentials of a person: M1-36, M1-40.
resolve_i1onaddOwner: M2-21.- Real authentication with DPoP and sessions: M1-34 and M1-35. This ticket tests with a test authenticator.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-31-identity-people, created from the base branch named by the launcher. One writer per worktree. - Conventional Commits with the module as scope (for example
feat(identity): manage people and owners) and a DCO sign-off on every commit (git commit -s). Squash into meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope of this brief, except
docs/DECISIONS.md,services/server/CHANGELOG.mdand the generated files named here. - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0 (P-10); PostgreSQL throughpgtest(P-09), never skipped. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools.
- No gate may be weakened, no threshold lowered, no test deleted or skipped.
Design notes¶
- Step-up, owner-only and session-only checks are decisions of
authorize(07 section 4.2 steps 6 and 8); the use cases never inspect the actor for them. - Production wiring: no agents and no structure exist yet, so the empty sources of M1-28 and M1-29 are wired; I1 is exercised in tests with a fake
AgentPolicySourceand a fakeStructureSource(plan row: AZ-M23 with a fake agent source). - Ruling:
createUseremitsuser.updated, because the catalogue has nouser.createdand clients treat the event as an upsert of a directory entry. - Ruling:
addOwnerfor a non-active person is409 conflict; an owner must be able to act at once. - Ruling:
enableUseron an active person is an audited no-op with200. - Ruling:
revokeOwnerfor a person without an active owner row is404, because the owner role is the resource. - Problem codes:
400 validation_failedwitherrors[],400 cursor_invalid,401 unauthenticated,401 step_up_required,403 forbidden(owner_only,step_up_not_possible,subject_kind_not_allowed),404 not_found,409 conflict,409 invalid_state_transition,409 i1_violation,409 last_owner,422 idempotency_key_reuse(M1-20). - Response validation: use the response validator of the merged tree if one exists; otherwise add a test-only helper in
services/server/test/apitestthat validates each response body against its component schema indocs/api/openapi.yamlwithgithub.com/santhosh-tekuri/jsonschema/v6(Apache-2.0, JSON Schema 2020-12 as used by OpenAPI 3.1), pinned ingo.mod, checked bymake licenses. - Test authentication: use the fake authenticator of
internal/platform/httpserver/httpservertest(M1-18); add the builders it lacks there (owner in a session with fresh or old step-up, owner with a personal API token, human without owner role, human on a pending device, agent, anonymous). Test helpers live in the tree of a component (02 "6. Code layout"); no package underinternal/kernel/actor.
Tests (write them first)¶
HTTP-level integration tests through the real chain (M1-18 to M1-20) with the test authenticator, PostgreSQL 18 through pgtest, controllable clock, fake AgentPolicySource and StructureSource where I1 matters. Every response is validated against its schema.
- No self registration: anonymous
POST /api/v1/usersis401; a human without owner role gets403(owner_only); no other built operation inserts a person (assert the users table after calling all ten operations as non-owner). - Step-up:
createUser,updateUser,enableUser,addOwner,revokeOwnerwithout fresh step-up are401 step_up_required(AZ-M36 shape); with an owner's personal token403 forbiddenwithreasonstep_up_not_possible(AZ-M35 shape, SR5);getMeandupdateMyProfileneed no step-up. TestAZ_M23_AddOwnerWithPersonalAgent: with the fake source reporting agent Assistent as personal with the owner as holder,addOwnerfor Alex is409 i1_violationwithconflictsnaming the agent, the holder and Alex; nothing changed; one deny audit row.- Last owner:
revokeOwnerof the sole owner, also by that owner, is409 last_owner. - Owner race: two owners, two concurrent
revokeOwnercalls removing each other: exactly one204, one409 last_owner. addOwnerhappy path without personal agents:201,user.updatedwithis_owner = true,access.changedfor the new owner (the fake structure has channels), auditowner.addwith reason,listOwnerscontains the person.enableUser:pendingtoactive;disabledtoactivewithdisabled_atcleared;deletedis409 invalid_state_transition; already active is200without event; enabling a disabled second owner while the fake agent source reports a personal agent is409 i1_violation; enabling a person with atriggergrant on a channel served by the personal agent is409 i1_violation.- Validation: handle with upper case or leading dot, handle longer than
principal.handle_max_chars, duplicate handle in other case, duplicate e-mail, display name with a bidi override or control character (SEC-T07), unknown time zone,Local, malformed locale,local_account: true: each400 validation_failedwith the pointer of the field, or409 conflictfor the duplicates. getMe: human owner with fresh step-up (all fields,step_up_valid_untilset), human with old step-up (field absent), agent principal (noemail,is_owner = false), pending device allowed.updateMyProfile: changes own fields and emitsuser.updated; an agent gets403; invalid values400.listUsers: stable keyset pages while rows are inserted between requests; cursor reused with another filter is400 cursor_invalid;qprefix on handle and display name, with%and_treated literally;statusfilter;limitaboveapi.page_size_maxis capped; nototalmember; an agent caller gets403.getUser: unknown ID and the ID of an agent principal both give the same404body.- Idempotency:
createUserrepeated with the sameIdempotency-Keyreturns the stored response withIdempotent-Replay: trueand creates one person; the same key with another body is422 idempotency_key_reuse; repeating after a401 step_up_requiredwith the same key succeeds once step-up is fresh. - Audit: entries for
user.create,user.update,user.enable,owner.add,owner.removewith the details of Scope 6; a search of all auditdetailsand captured logs for the test display names and e-mail addresses finds nothing. - AZ-P1: the route test counts the ten new operations, each with its action from
actions.yaml.
Commands that must pass:
cd services/server && go test -race ./internal/identity/... ./internal/authz/... ./test/...make codegen-check,make arch,make api-lint,make licenses,make docsmake check
Acceptance criteria¶
- The ten operations are reachable, in
operations.yaml, and every response matches its schema. - No self registration (test 1).
- Owner actions only by owners in a session with fresh step-up (test 2).
- AZ-M23 green with a fake agent source; last owner and owner race green.
-
user.enablecoverspendinganddisabledand checks I1. -
user.updatedemitted for create, update, enable, owner changes and own profile. - SEC-T07 for people green; no names or e-mail addresses in audit details or logs.
- README, DECISIONS and CHANGELOG updated; doc comments on every exported symbol.
-
make checkgreen.
Definition of done¶
- Code, tests and documentation as listed in Scope; the module README is current (operations, ports, events, tables).
- A doc comment on every exported symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- An ADR for every architecture decision taken here (none expected; rulings go to
docs/DECISIONS.md). - CHANGELOG entry under Unreleased in
services/server/CHANGELOG.md. make checkgreen on the final commit.- Independent review of the diff (
make reviewplus a code review by a fresh reviewer) and the red team ofdocs/quality/red-team-gate.mdover the diff, verdict recorded in the report. OnNEIN: fix, add a test per finding, re-check the delta. - Merge locally into the base branch only when the review is clean, the red team says
JAandmake checkis green; thengit worktree remove.
Report¶
Write {{REPORT_PATH}}:
- first line exactly
Verdict: DONEorVerdict: BLOCKED <reason>; - commits (sha and subject), files changed with line counts, test results with counts (zero skipped), the red output of the first run of each new test file, what is not proven, follow-ups that could not be done in scope with the reason.
Then write {{DONE_PATH}} with exit=0 sha=<merge sha> or exit=1 <reason>.