# 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`:

```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`:

```json
{
  "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

```php
$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.
