Principal lists

The central registry also supports stable organization ownership references (kind=org, source_kind=org, nil tenant, native org UUID as source). They are exported through authorized SSO /userinfo/orgs operations for principal-oriented ownership uses. This does not make an org an access-list actor: member operations below continue to accept only users and apps. List owner selectors, org rules, and ordinary API User/SSO operations continue to use native organization UUIDs.

These POST routes use the API v2 base URL, JSON bodies, and a validated API.User access token. They manage central actor identities, list definitions, and actor-to-list associations. They do not change OAuth application ownership or organization Management membership.

Route Request fields Result
/principal/me/get None The caller's normalized M7 principal.
/lists/search Optional owner_type (user or org), owner (org UUID, only with owner_type: "org"), limit, page_number, offset, cursor A paged set of lists the caller owns, can read through live Management membership, or can read through an active association. owner_type: "user" returns only lists owned by the signed-in user; org returns only accessible org-owned lists. Supplying owner scopes the page to that organization and requires its live Management membership.
/lists/view id One list definition.
/lists/insert owner_type (user or org), slug, name; owner for an org; optional pool (consumer or tenant), data, allowed_roles, and default_role A new list definition with one immutable human account pool.
/lists/update id and at least one of name, data, allowed_roles, default_role, or status The updated definition.
/lists/members/search list; optional status and pagination fields Paged associations with their normalized principal records.
/lists/members/add list, principal, kind (user or app); optional expires, role, data A stable association.
/lists/members/set list, principal, and at least one of status, expires, role, or data The updated association.
/lists/members/remove list, principal The association with status disabled.
/lists/members/delete list, principal Permanently deletes the association row so the actor can be added again with fresh access settings.

Each route has a matching scope in config/route-scopes.json, such as lists.members.add. A valid scope is necessary but not sufficient for access. The service resolves the caller from the validated token. principals.id is a stable normalized ID, distinct from the native account ID in principals.source. The native source is the OAuth client row ID for a machine or the M7 user ID for a consumer. A principal is unique by source_kind, tenant, and source, so IDs from different identity directories cannot collide. Machine owner-proxy credentials are rejected by this family. Organization-member tokens are not yet supported here.

For a personal list, owner is derived from the signed-in user; an explicitly supplied owner must match. For an org list, owner is the organization ID. pool chooses which human accounts the list can contain. A personal list always uses the consumer pool; an org list can choose consumer or tenant. For tenant lists, tenant users must belong to the owning organization. The stored tenant field represents this choice as the nil UUID for consumers or the owner org UUID for tenant users. The pool cannot change after creation. For compatibility, tenant may still be supplied on insertion, but when pool is present the two must agree. Machine applications are separate from the human pool: an active machine application can be explicitly attached to either kind of list regardless of its own tenant. A browser application cannot be added as a machine member. These rules are checked on addition, reactivation, and association-based reads. Management access to an org list remains independent of its member pool. An active Management owner or admin can create and change an org list and its associations. Any active Management role can read an active org list, as can a principal with an active, unexpired direct association. A personal owner can manage their list. Disabled definitions remain visible to their managers. An inactive or archived organization makes its lists unavailable to readers.

principal on /lists/members/add is the native M7 user ID or OAuth client row ID, not its public client_id. add resolves that record from current M7 storage in the list's human pool: consumer users come from user, and tenant members come from client scoped to the owning organization. The association stores the resulting normalized principals.id. The principal input on set, remove, and delete is that normalized ID, as returned by member search. Tenant-user caller tokens are not yet supported by this endpoint family. add does not reactivate an existing disabled or expired association; use set to change its status or expiry. expires is a UTC YYYY-MM-DD HH:MM:SS value or null. The accessed timestamp is service-written and cannot be changed through these routes. data is an optional JSON object or array on the list definition and is not interpreted as an access policy. Omitted or cleared data is stored as SQL NULL and returned as an empty array by the shared repository. Each list definition has allowed_roles, an ordered JSON array of up to 64 unique lowercase role keys that must include member, and optional default_role. A new list offers only member until more choices are defined; when default_role is null, new associations use member. The default must name one of the allowed roles. Role keys start with a lowercase letter and may then contain lowercase letters, digits, ., _, :, or - (128 characters maximum). Existing lists with no stored role choices use the same member fallback. Removing or renaming an option does not rewrite existing associations: live lookups still return a member's stored role. A retired role cannot be newly assigned, but that member's status, expiry, and data can still be edited without changing the role. Removing a configured default requires selecting another allowed default or clearing it in the same list update. Each association also has a role and optional JSON data. These are separate from the list definition's data and organization Management roles. SSO projects the member's role and data only when its effective list status is active. The personal and organization list editors expose role choices and the default; member editors select a role from a dropdown, including an existing retired role for an unchanged-role edit.

allowed_roles and default_role are columns on principal_list_desc, not a separate role table. For an existing production table, apply m7-php-platform/package/auth/user/registry/sql/principal_lists/migrations/20260930-list-role-choices.sql before deploying code that reads these columns. Its production ALTER is:

ALTER TABLE `principal_list_desc`
    ADD COLUMN `allowed_roles` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL AFTER `data`,
    ADD COLUMN `default_role` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL AFTER `allowed_roles`,
    ADD CONSTRAINT `principal_list_desc_chk_roles` CHECK (json_valid(`allowed_roles`));

The nullable columns preserve member as the effective choice and default for existing lists. Source and local tests establish this upgrade requirement; no production migration or deployment is asserted here.

For an installation still missing the earlier human-pool schema, the separate PrincipalListDesc::migrateIdentityDirectory() maintenance migration is also required before deploying code that reads that schema. It does not add the role-choice columns. It inspects human members, refuses lists that already mix consumer and tenant users, and requires a pool assignment for each org list without human members. Machine members do not determine the pool. Run it with list writes stopped; no startup path silently migrates existing rows.

Both search routes always return the pagination envelope in data, even for an empty result. The default page size is 20 and maximum is 100.

The SSO principal projection and downstream resource checks are separate from these management routes. A list is a membership and policy layer for consumer or tenant identities and machine applications; it does not replace an organization's Management membership or grant access to a service's resources.

An OAuth application can store a principal-list enrollment configuration with POST /oauth/clients/set_principal_list. Send the application row id, list (a list UUID), and initial_status (active, pending, or disabled). Two optional boolean settings are reject_disabled_members (deny a disabled member at token issuance instead of returning a token with disabled access status) and require_active_machine_membership (deny client credentials unless the machine principal has an active, unexpired membership in that list). A new binding defaults both to false; omitted settings on the same list retain their stored values. Send an empty list and no initial_status to clear the binding. The selected list must be active and owned by the application's personal user or owning organization. The route uses the oauth.clients.set_knobs scope and requires the current configuration lock when one is set. The application view returns principal_list_id, principal_list_initial_status, principal_list_reject_disabled_members, principal_list_require_active_machine_membership, principal_list_enrich_token_with_role, and principal_list_enrich_token_with_data, plus the projected principal_list_acl. The central service uses the binding and rejection settings during enrollment and token signing; see the SSO authorization contract.

Optional boolean enrich_token_with_role and enrich_token_with_data independently include the current active member's association fields in newly issued access tokens. Both default to false; omitted values on the same list are preserved, and switching lists resets them. Large JSON member data increases token size. UserInfo and receiver ACL introspection still return the current active member role and data regardless of these switches. These are application token-output settings; the list's allowed_roles and default_role govern assignment and do not turn on token enrichment. Pending or denied projections omit both fields.

Optional acl contains an ordered list of at most 200 inbound machine-token rules. Each rule has type (app or org) and policy (active, pending, or disabled), matching the possible initial_status values. Previously stored allow and deny rules reopen as active and disabled. App rules identify a registration by public client_id; org rules identify an organization by UUID in id. The first matching rule selects the receiver-list membership policy; initial_status applies when nothing matches. Each rule also has optional token_expires_after_seconds. Despite its name, this is a sliding membership lease for inbound ACL checks, not a cap on the issued token's lifetime. A positive whole-second value proposes a new UTC membership expiry at each qualifying check, bounded by the inbound token's signed expiry. null proposes no new expiry. The editor defaults the value to 900 seconds when its switch is enabled. These ACL rules are evaluated by the receiving app's opt-in SSO ACL introspection, not by ordinary token issuance.

SSO's opt-in POST /introspect/acl evaluates these rules against an active client-credentials token addressed to the authenticated receiving application. The receiver authenticates to SSO, which checks for an exact signed token audience registered in that receiver's allowed_audiences; the receiver's Client ID need not be in aud. The first verified check can create the machine's receiving-list principal and association. Later qualifying checks extend principal_lists.expires only when the proposed deadline is later; disabled associations are not reactivated. This shared expiry is visible to other applications using the list. The rule duration also bounds response caching by the token's signed expiry. Neither the stored lease nor a policy change alters or revokes an already-issued token. Call the ACL endpoint on each protected request when policy removal must take effect immediately. Its response includes a receiver-list principal projection (id, status, list, and for active memberships role and data) using the same stable ID scheme as token claims. This projection is based on the current inbound machine and receiving list, not the token's own m7.principal claim.

App rules are stored against the resolved registration row UUID; responses add target names and availability for editing. An unchanged app rule may send its bound id alongside client_id so a recreated Client ID cannot silently change the target.

A new list binding starts with an empty ACL. Omitting acl while saving the same list preserves its rules; switching lists resets them. Clearing the list binding removes the ACL. The organization discovery ACL is separate.

Ordinary list-backed authorization

When an application is bound to a list, new human sign-in, token issuance, and refresh check the current association, including expires. A missing member is initially enrolled with the application's Active, Pending, or Disabled setting. Existing status is preserved. Expired or disabled members project m7.principal.status: denied into newly issued access tokens; pending remains pending. The app's reject_disabled_members setting decides whether a denied member is rejected before issuance. A machine app can instead require an existing active, unexpired association with require_active_machine_membership. Applications must enforce the projected status when authorizing a resource. Active tokens carry the member's current role and data at issuance only when the corresponding token-enrichment switches are enabled. Updating the shared association does not rewrite an access token already issued. Standard /userinfo returns the current projection for a validated list-bound token with openid, after matching its signed principal ID and list to the application's current binding. It never upgrades a signed pending or denied token to active.

Serverless entry for a consumer service

For a consumer application, M7 can supply identity, list enrollment, the ordered inbound machine policy decision, and a shared expiring membership record. A receiving service can use the verified receiver-list principal ID instead of building an account enrollment table solely for that boundary. It still authenticates to SSO, checks the decision and principal status, verifies DPoP or mTLS proof on a sender-bound inbound request, and enforces its own routes, scopes, object ownership, and resource permissions. Local application state and business authorization remain the service's choice. Mail currently exposes an isolated ACL test route; this contract does not replace Mail's internal authorization or imply general production enforcement.