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.