# 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](https://m7.org/docs/api/api.user.m7.org/README.md#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:

```sql
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](https://m7.org/docs/api/api.user.m7.org/README.md#pagination)
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](https://m7.org/docs/api/sso.user.m7.org/authorization.md#inbound-machine-principal-list-acl).

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.
