Manage an application's ACL membership
Status: implemented and tested locally on 2026-10-04. Deployment and live verification are pending. This operation is available in updated SDK source and the locally built and verified Token/PHP 0.1.5-rc.1 candidate. Published Token/PHP 0.1.4 does not provide it. The candidate is not a public release; verify the installed SDK and deployed endpoint separately.
POST https://sso.user.m7.org/acl/members/upsert creates or updates a known
global user or machine principal's membership in the authenticated application's
attached Principal List. No API User access token is needed.
Configure the registration
In User M7, select the application's Principal List, then enable Allow
application to manage members and save. The protected API User route
POST /api/v2/oauth/clients/set_principal_list accepts boolean manage_members
alongside id, list, and initial_status. It requires the existing
oauth.clients.set_knobs scope, personal app ownership or the current stored
organization Management owner role for an org-owned app,
and the current configuration lock when configured.
The stored flag is config.principal_list_manage_members; the application view
also returns principal_list_manage_members. Its default is false. Omitting it
while saving the same list preserves its value. Switching lists resets an
omitted permission to false; detaching the list removes it. Generic registration
creation/update cannot set this flag directly.
The receiving application must be active, have an active attached list, and retain the list's owning account or organization. Its owner must remain active. SSO reads the registration and list on every call. Missing lists fail before principal, list, or membership writes. Requests cannot select a different list or create an implicit list.
Authenticate
Use the receiving app's configured confidential client-authentication method:
| Method | Request |
|---|---|
client_secret_basic |
OAuth HTTP Basic header with the public Client ID and secret; form-encode each component before constructing Basic. |
client_secret_post |
client_id and client_secret in the request body. |
client_secret_jwt |
client_id, client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer, and a fresh secret-signed client_assertion in the body. |
private_key_jwt |
The same assertion fields, signed by the registered private key. |
tls_client_auth |
client_id in the body and the registered PKI client certificate on the verified TLS connection. |
self_signed_tls_client_auth |
client_id in the body and the client certificate matching the registered key on the TLS connection. |
JWT assertions use the existing registered signing algorithms, exact endpoint
audience or canonical SSO issuer audience, time/subject/client checks, and JTI
replay protection. Do not reuse an assertion. Certificate methods use Apache's
direct verified TLS environment, never request-supplied certificate headers.
Their deployment requires the configured mTLS SSO listener/alias at
https://mtls-sso.user.m7.org/acl/members/upsert; availability has not been
confirmed live. Shared authentication penalties and the registration's IP
whitelist apply. Public none clients and bearer-token authorization cannot
manage the list. An incoming token being considered for admission confers no
management authority.
Request and membership contracts
Send Content-Type: application/json:
{
"principal_id": "10000000-0000-7000-8000-000000000004",
"status": "active",
"role": "member",
"expires": "2030-10-04 00:00:00",
"data": {"quota": 7}
}
The example UUID is synthetic. Use the global ID from a trusted M7 principal projection or list membership. This is distinct from a native user/member ID, OAuth registration row ID, and public Client ID. The registry principal must already exist and be active. SSO checks its live consumer account, tenant member, or machine registration; unavailable, archived, expired, changed, or non-machine app identities fail. Human accounts must belong to the list's immutable identity pool; machines are independent of that human pool. Organizations are not eligible membership targets.
| Field | Contract |
|---|---|
principal_id |
Required non-nil dashed UUID; trimmed and normalized to uppercase. |
status |
active, pending, or disabled. Required for new memberships; optional for existing ones. |
role |
Optional allowed list role. New members use default_role, or member. Existing retired roles can be retained unchanged. Null/empty cannot clear a role; submit another allowed role. |
expires |
Optional UTC YYYY-MM-DD HH:MM:SS. JSON null explicitly removes expiry. Past expiry remains expired even with Active status. |
data |
Optional JSON object/array. JSON null explicitly clears data; shared storage projects cleared data as []. |
At least one membership change is required. Repeating a supplied value is an
idempotent update. Omitted fields are preserved, including expiry when approving
Pending or Disabled members. Updating role/data does not renew a lease.
Unknown fields, including list, list_id, and token fields, fail. Bodies are
limited to 65,536 bytes.
OAuth callers may also send application/x-www-form-urlencoded: encode data
as JSON and use the literal string null to clear expires. Authentication
fields are separated from membership changes.
Response and errors
Both creation and update return HTTP 200, with Cache-Control: no-store:
{
"list": {
"id": "10000000-0000-7000-8000-000000000005",
"slug": "service-acl",
"name": "Service ACL"
},
"membership": {
"id": "10000000-0000-7000-8000-000000000008",
"principal": "10000000-0000-7000-8000-000000000004",
"list": "10000000-0000-7000-8000-000000000005",
"status": "active",
"role": "member",
"expires": "2030-10-04 00:00:00",
"data": {"quota": 7},
"accessed": null,
"created": "2026-10-04 00:00:00",
"modified": "2026-10-04 00:00:00"
}
}
The membership is the canonical shared-service association. Updates preserve
its ID and accessed; concurrent first upserts converge on the unique
(principal, list) association. Competing changes to the same field use the
last successful write; disjoint patches preserve other fields. Every app
attached to the list observes these changes.
Errors use the existing JSON error/error_description contract:
| HTTP | Error | Meaning |
|---|---|---|
| 400 | invalid_request |
Invalid body/field, missing creation status, invalid role/data/expiry, or no changes. |
| 400 | invalid_client |
Missing or invalid configured client authentication, including assertion/certificate failures. |
| 400 | unauthorized_client |
The existing IP policy denied the request. |
| 400 | invalid_principal |
Unknown/ineligible principal, incompatible human pool, or unavailable live identity. |
| 403 | unauthorized_client |
Public/bearer authorization, inactive/changed registration or owner, or missing management permission. |
| 403 | attached_list_required |
No attached list; no registry mutation occurred. |
| 403 | list_unavailable |
Missing/inactive list or ownership mismatch. |
| 405 | invalid_request |
POST required. |
| 429 | Existing security-policy response | Authentication penalty/rate limit applies. |
| 503 | server_error |
Storage or endpoint operation unavailable; the existing system-error response may include a trace ID. |
The shared authentication or invalid IP-configuration handler may also return its established server-error response.
SDK example
$result = $identity->upsertAclMember($principalId, ['status' => 'active'], [
'auth' => [
'method' => 'client_secret_basic',
'client_id' => $receiverClientId,
'client_secret' => $receiverClientSecret,
],
]);
if (!$result->ok()) {
throw new RuntimeException($result->reason());
}
$membership = $result->membership();
$resolvedList = $result->list();
The source PHP SDK wrapper reuses its shared HTTPS transport and authentication infrastructure for Basic, Post, PKI mTLS, and self-signed mTLS. Assertion clients currently use direct HTTP with the assertion fields above; JWT assertion generation is not implemented in the SDK's shared client-authentication helper. The SDK validates the result's list/principal bindings and response size.
Admission boundaries and deployment
Active membership does not override the receiver's Pending/Disabled ordered machine admission policy. Organization Management membership, org disclosure policy, and machine admission rules are independent. Owner proxies still require both proxy and registered owner to have Active, unexpired service memberships. The new permission grants membership management only; it does not alter those checks or device-first enrollment behavior.
Deploy the SSO endpoint/helper and route, API User permission setter/projection, and User M7 control together. The existing shared PrincipalLists service and role/pool-capable storage must be installed; this change adds no tables or schema migration. The updated SDK source has a locally verified 0.1.5-rc.1 build; public release and endpoint deployment remain separate steps. After deployment, an authorized registration manager must explicitly enable the permission on each intended receiving registration. Configure any necessary mTLS listener and IP access policy. Verify the configured auth method, rejection with permission off/no list, and an approved synthetic member against a dedicated test registration/list before relying on it. No production changes were made as part of the local implementation.