Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 255 additions & 3 deletions api/control.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,124 @@ paths:
application/scim+json:
schema:
$ref: '#/components/schemas/UserResource'
patch:
tags:
- Users
operationId: patchUser
summary: Patch a user
description: |
Applies a SCIM patch to the user that matches the supplied id. Only a `replace` operation
with no path is supported, whose value sets the user's `active` flag — this is how a user
is deactivated and reactivated.

Deactivation takes effect on the next request: the server re-reads the user's state on
every call, so access tokens already issued stop working immediately. Nothing else about
the user changes — their grants, the objects they own and their email are left as they
were, which is what makes it reversible.
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchRequest'
responses:
'200':
description: The user was successfully patched.
content:
application/scim+json:
schema: {}
'501':
description: The patch contained no supported operation.
delete:
tags:
- Users
operationId: deleteUser
summary: Delete a user
summary: Deactivate or permanently delete a user
description: |
Deletes the user that matches the supplied id.
Without `purge`, deactivates the user — the same state change as a SCIM patch setting
`active` to false, and equally reversible.

With `purge=true`, removes the user record permanently. This is the only way to free the
user's email and externalId for reuse; it does not block access any harder than
deactivation, which already refuses the user on every request. Because it cannot be undone,
the server requires that:

* the caller is a metastore administrator, and is not the user being deleted;
* the user is already deactivated;
* `confirm_principal` repeats the user's principal exactly;
* another enabled metastore administrator remains afterwards;
* the built-in `admin` account is never the target — it is the account behind the
password sign-in, the way back in when the identity provider is unavailable;
* `reassign_to` names an enabled user, if the target owns anything.

Ownership transfers in both places it is recorded: the `owner` field of each object, and
the OWNER privileges that actually authorize. `created_by` and `updated_by` are left alone —
they record what happened, which stays true.
parameters:
- name: purge
in: query
description: |
Permanently remove the user record instead of deactivating them. Cannot be undone.
required: false
schema:
type: boolean
default: false
- name: reassign_to
in: query
description: |
Principal (email) taking ownership of everything the user owns. Required with
`purge=true` when the user owns anything; must name an enabled user other than the
target. Prefer the administrator performing the deletion over the built-in `admin`
account, which may itself be deactivated.
required: false
schema:
type: string
- name: confirm_principal
in: query
description: |
The target user's principal, repeated back. Required with `purge=true`. The user list
mixes people with Relyt instance principals, which look alike; this is what catches
the wrong row being selected.
required: false
schema:
type: string
responses:
'200':
description: The user was successfully deleted.
description: The user was successfully deactivated or deleted.
content:
application/scim+json:
schema: {}
'400':
description: |
A guard refused the purge — missing or mismatched `confirm_principal`, the user is
still active, the target is the caller or the built-in administrator, no other enabled
administrator would remain, or `reassign_to` is unusable.
'409':
description: |
The user owns objects and no `reassign_to` was supplied.
/scim2/Users/{id}/ownedObjects:
parameters:
- name: id
in: path
description: The id of the user.
required: true
schema:
type: string
get:
tags:
- Users
operationId: getUserOwnedObjects
summary: List what a user owns
description: |
Every securable whose owner is this user, so that a caller can see the consequences of
deleting them before anything happens. One indexed query per securable type, unlike the
per-object walk needed to enumerate a user's privileges.
responses:
'200':
description: The owned objects were successfully retrieved.
content:
application/json:
schema:
$ref: '#/components/schemas/OwnedObjectsResponse'
/scim2/Me:
get:
tags:
Expand All @@ -132,6 +237,71 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/UserResource'
/metastore/admins:
get:
tags:
- Metastore
operationId: listMetastoreAdmins
summary: List the metastore's administrators
description: |
The users holding OWNER on the metastore, deactivated ones included. This is the only way
to see that: OWNER is filtered out of `/permissions` responses, so no other endpoint
reveals who holds it.
responses:
'200':
description: The administrators were successfully retrieved.
content:
application/json:
schema:
$ref: '#/components/schemas/MetastoreAdmins'
/metastore/admins/{email}:
parameters:
- name: email
in: path
description: The user's principal.
required: true
schema:
type: string
put:
tags:
- Metastore
operationId: grantMetastoreAdmin
summary: Make a user an administrator
description: |
Grants the user OWNER on the metastore, which is what being an administrator means: it
authorizes every securable, including ones they do not personally own.

Any administrator may do this. Metastore OWNER is the top of the privilege lattice, so an
administrator granting one has escalated nobody past themselves — whereas restricting it
to the built-in `admin` account would make the break-glass password sign-in a participant
in routine administration.

The user must exist and be active; ownership of a deactivated account authorizes nothing
and would only obscure who is really in charge.
responses:
'200':
description: The user is now an administrator.
'400':
description: The user is deactivated.
'404':
description: No such user.
delete:
tags:
- Metastore
operationId: revokeMetastoreAdmin
summary: Withdraw a user's administrator privilege
description: |
Revoking your own is allowed — handing over is a real thing to want — but only while
somebody else still holds it. At least one *enabled* administrator must remain: a
deactivated one cannot sign in, and a metastore left without one has no supported way back,
because the bootstrap re-grants OWNER only when the `admin` user does not exist at all.
responses:
'200':
description: The user is no longer an administrator.
'400':
description: This would leave the metastore without an enabled administrator.
'404':
description: No such user.
/auth/capabilities:
get:
tags:
Expand Down Expand Up @@ -489,6 +659,88 @@ components:
description: |
Whether the caller owns the metastore. Metastore owners are authorized for
every securable, including ones they do not personally own.
MetastoreAdmins:
type: object
properties:
admins:
type: array
description: Principals holding OWNER on the metastore.
items:
type: string
PatchRequest:
description: |
A SCIM patch. Only a single `replace` operation with no path is honoured, and its value
sets the user's `active` flag.
type: object
properties:
schemas:
type: array
items:
type: string
example:
- urn:ietf:params:scim:api:messages:2.0:PatchOp
Operations:
type: array
items:
$ref: '#/components/schemas/PatchOperation'
PatchOperation:
type: object
properties:
op:
type: string
description: The patch operation; only `replace` is supported.
example: replace
path:
type: string
description: >
The attribute to replace. Omit it to send the attributes as an object in `value`
(the form Okta and this project's UI use); set it to `active` to send the bare value.
`active` is the only attribute this endpoint changes; anything else is answered with
501.
example: active
value:
type: object
description: >
With no `path`, the object of attributes to replace — RFC 7644 §3.5.2.1 requires an
object here, not a bare value, and the server's SCIM library enforces that while
parsing, so a scalar is refused before any handler runs. With `path` set, this is
instead that attribute's new value (for `active`, a boolean).
properties:
active:
type: boolean
description: The user's new `active` state.
example:
active: false
OwnedObjectsResponse:
type: object
properties:
principal:
type: string
description: The user whose objects these are.
owned:
type: array
description: |
Every securable this user owns. Empty means the user can be deleted without naming
anyone to take ownership.
items:
$ref: '#/components/schemas/OwnedObject'
OwnedObject:
type: object
properties:
securable_type:
type: string
description: |
The kind of securable, named as in /permissions/{securable_type}/{full_name}.
example: table
full_name:
type: string
description: |
The securable's qualified name. Falls back to the bare name when an ancestor has
already been deleted — a catalog's removal does not clear what was under it.
example: sales.raw.orders
securable_id:
type: string
description: The securable's id.
UserResource:
description: SCIM provides a resource type for "User" resources.
type: object
Expand Down
75 changes: 72 additions & 3 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,17 @@ friends run server-side); the administrator, as metastore owner, sees everything
`deploy-uc.sh` warns when authorization is on but neither entry point is configured, since nobody
could sign in.

**Signing in with an access token.** Whoever runs the server can sign in with the token it writes
to `etc/conf/token.txt` at `<ui>/login/token`, pasting it into the form or opening
**Signing in with an access token.** Paste a **user's** access token at `<ui>/login/token`, or open
`<ui>/login/token?token=<token>`; the page drops the token from the address bar once it is used.
The server verifies the token the way it verifies any request and hands it back as the session
cookie, so the session ends when the token does.
cookie, so the session ends when the token does. You are signed in as the user the token's `sub`
names, with exactly that user's privileges.

> ⚠️ The token in `etc/conf/token.txt` **cannot sign in.** Its `sub` is `server` — it is the
> server's own service token — and no user with that email is ever created, so
> `/auth/token/login` refuses it with `no enabled user named 'server'`. To get a token that can
> sign in, exchange one at `/auth/tokens`, or sign in at `<ui>/login/admin` and take the `UC_TOKEN`
> cookie.

Earlier versions had the UI server inject that token into every API call instead, which made the
UI's own address a way into an authorized server with no sign-in at all. The UI server now adds no
Expand Down Expand Up @@ -437,6 +443,69 @@ password is stored in the rendered `server.properties` alongside the other secre
constant time; a failed attempt is delayed by a second, which slows but does not stop guessing — use
a long random value and keep the UI behind your usual network controls.

#### `admin` is a break-glass account, not an operations account

Being an administrator is not an account type in UC; it is a fact: the user holds `OWNER` on the
metastore resource. That is exactly what `metastore_admin` from `GET /auth/capabilities` reports.
In principle it can be granted and revoked, and several people can hold it at once.

On the UI's Users page an administrator can "Make administrator" / "Withdraw administrator" for any
**active** account, and administrators carry an `Admin` tag in the list. The endpoints behind it:

```bash
# Promote
curl -X PUT -H "Authorization: Bearer $TOKEN" \
"$UC_URL/api/1.0/unity-control/metastore/admins/ops@corp.com"

# Withdraw
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"$UC_URL/api/1.0/unity-control/metastore/admins/ops@corp.com"

# See who they are (nothing else shows this: OWNER is filtered out of /permissions)
curl -H "Authorization: Bearer $TOKEN" \
"$UC_URL/api/1.0/unity-control/metastore/admins"
```

**Any administrator may promote and withdraw, not just the built-in `admin`.** Metastore OWNER is
already the top of the privilege lattice, so an administrator granting one has escalated nobody
past themselves; restricting it to `admin` would instead drag the break-glass password account into
routine administration, which is the habit this section warns against.

The server holds two lines: a deactivated account cannot be made an administrator (it cannot sign
in, so it would only obscure who is really in charge), and **at least one enabled administrator
must remain** after a withdrawal. The second is not ceremony: `UnityAccessUtil#initializeAdmin`
re-grants OWNER only when the `admin` user does not exist *at all*, so an `admin` row that survives
without the privilege is never repaired by a restart.

> Background: `OWNER` is deliberately absent from the public `Privilege` enum, so it cannot travel
> through `/permissions` — hence the dedicated endpoints above. It is kept out of that enum because
> the enum is shared by every securable: admitting `OWNER` would simultaneously open ownership
> transfer on catalogs, schemas and tables, which is a separate feature needing its own design.

**Run day-to-day administration from real accounts holding metastore OWNER, not from a shared
`admin` password.** Three reasons:

1. **It bypasses every Entra control.** `<ui>/login/admin` never touches Entra, so MFA, conditional
access and "disabled on termination" do not apply to it. Share the password and someone disabled
in Entra after leaving still signs in to UC with the copy they kept.
2. **It destroys attribution.** In the server log — and in any audit record built later — the actor
is always `admin`, with no way to tell who acted. For deleting users, reassigning ownership or
handing out cloud credentials, that is the same as having no audit trail.
3. **It is the only way back in, and using it routinely is how you lose it.** The `admin` account
itself can be deactivated or have its privileges reduced (see the comment on
`UnityAccessUtil.initializeAdmin`), and the password sign-in **does not check that the account
still exists or is still ENABLED**. Once it is disabled or deleted, `<ui>/login/admin` still
returns success and sets the cookie, but every subsequent API call is rejected with 403 — a
dead end that looks like a working session. Keeping it for emergencies avoids ever getting there.

**In production, leave `UC_ADMIN_PASSWORD` empty.** With it empty, `GET /auth/providers` does not
report `admin_login` and the `<ui>/login/admin` entry point exists neither on the login page nor on
the server. Set it and restart only when you actually need it (Entra outage, every administrator
locked out), then clear it again.

> `<ui>/login/token` is a break-glass entry point of the same kind and should likewise not be a daily
> habit; the `etc/conf/token.txt` token it uses never expires — see [Security notes](#security-notes).

### How programs reach UC

Customer programs do not sign in with Microsoft identities. They go through Relyt, whose coordinator
Expand Down
Loading
Loading