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.