Repository navigation
Commit f6a429b
feat(plugin-auth): no user-context restore of an API key revoked because its owner's membership ended (#31435)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} -->
_Requested by **Sam** · [project
thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HPXGFhC5rGwDNXu4au6YC5H8)_
Closes #28387
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:`sys_api_key.revoked_reason` 新增一个取值
`organization_membership_ended`(与会话的 `revoke_reason` 同一个词);带这个原因被撤销的 API
密钥,任何用户身份都不能通过 Restore 恢复,平台管理员也不行,服务端直接 403 并说明原因;被撤销密钥调用时的
`API_KEY_REVOKED` 提示也改为说明该密钥不可恢复。
- 为什么改:cloud 在成员被移出组织时会吊销他在各环境的密钥,但环境管理员可以用 Restore
把它恢复,等于把非成员的凭据重新打开。框架原先无法分辨这种吊销原因。
- 风险与回滚:只影响带这个新原因的密钥;`admin_password_reset`
的规则不变,系统上下文写入不受影响。框架自身不写这个原因,cloud 的写入在后续卡里做。回滚即撤销本 PR。
- 先看:`packages/plugins/plugin-auth/src/api-key-restore-guard.ts`(规则
1,在读取管理员身份之前判定)、`packages/core/src/security/api-key.ts`(提示文字的新分支)、`packages/platform-objects/src/identity/sys-api-key.object.ts`(新选项与说明)。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/api-key-restore-guard.test.ts
src/forced-reset-revoke-reason-vocabulary.test.ts`,预期 24 个全绿。
- 需要你做的:无,席位落地(落地前席位做契约复核,其中一项偏离待裁定,见 Acceptance notes)。
## What changes
**The value.** `sys_api_key.revoked_reason`
(`packages/platform-objects/src/identity/sys-api-key.object.ts`) gains
the option `organization_membership_ended` ("Organization membership
ended") beside `admin_password_reset`. It is the word
`sys_session.revoke_reason` already carries when a membership removal
ends a session (`MEMBERSHIP_ENDED_REVOKE_REASON`, plugin-auth
`membership-ended-session.ts`), and the guard imports that same
constant. The column stays readonly and off the ADR-0092 D2 whitelist.
Its help text now names both reasons and who restores each; the six
translated locales carry the new option and the new help, and the en
bundle is regenerated (`check-i18n-bundles.mjs --write`). The platform
never writes the value: a host that removes someone from the
organization writes it in the system context, beside `revoked: true`.
**The refusal.** plugin-auth `api-key-restore-guard.ts` gains a rule
judged before any standing is read: a user-context un-revoke of a row
whose stored `revoked_reason` is `organization_membership_ended` is
refused `403 PERMISSION_DENIED` whoever the caller is, on the by-id path
and per row on a multi-row update. This is the server door the
`restore_api_key` row action reaches (`PATCH
/api/v1/data/sys_api_key/{id}`, `{ revoked: false }`); the action's
`visible` is display only and is unchanged. The refusal says the owner's
membership ended and that a member creates a new key (`POST
/api/v1/keys`, or the API Keys page). Unchanged: the
`admin_password_reset` rule and its message (byte-identical), the clear
on every admitted restore, the reason kept on a re-revoke, and a
system-context write is never refused, so a host can still re-admit a
key and the reason clears.
**The key door's text.** `@objectstack/core` `lifecycleRefusalOf`
(`packages/core/src/security/api-key.ts`) answered a revoked key with
"Its owner or a platform administrator can restore it" for every reason
but the forced reset. For a membership-ended key that is now false, so
it gets its own arm: revoked because its owner's membership in the
organization ended, cannot be restored, a member creates a new key. Code
`API_KEY_REVOKED`, status 403 and every carried field are unchanged.
Changeset: `patch` for `@objectstack/platform-objects`,
`@objectstack/plugin-auth` and `@objectstack/core` (`Metadata-protocol:
none`: the column is a system table's data value, not an authorable
metadata key).
## Why
cloud#3403 (PR cloud#3423) revokes a removed member's keys in every
environment, and named the follow-up: an environment administrator can
restore such a key. The framework had no way to tell that revocation
apart, because `revoked_reason` accepted only `admin_password_reset`. On
a `single` deployment no per-request membership check refuses the key
(the `organization_membership_ended` admission refusal in
`resolve-authz-context.ts` runs only under a wall-enforcing posture), so
the `revoked` flag is the only barrier, and anyone who could clear it
reopened a non-member's credential.
## Tests
- **plugin-auth `api-key-restore-guard.test.ts`**, new top-level
describe on the existing rig (real `ObjectQL` over SQLite, real
`SecurityPlugin` with the shipped permission sets, the D2 guard, the
guard under test, the real key door). Callers: the key's owner; a
platform administrator by grant (`admin_full_access`, promoted by
`bootstrapPlatformAdmin`); a platform administrator by the
`OS_PLATFORM_OWNER_EMAIL` anchor, with no grant row. Each admin case
asserts `hasPlatformAdminStanding` first and ends with a control: the
same caller restores a reset-revoked key, so the refusal is the reason's
and not a caller who cannot reach the row. Also: the host's system write
is stored with the reason; re-revoke and a smuggled `revoked_reason:
null` keep it; an admin batch restore including such a key is refused
whole; a system un-revoke lands and clears; the door text names no
restorer.
- **plugin-auth `forced-reset-revoke-reason-vocabulary.test.ts`**: the
option set is exactly the two values, and the new one equals the session
word that `sys_session.revoke_reason` names.
- **core `api-key-lifecycle.test.ts`**: the membership-ended arm of the
`API_KEY_REVOKED` text.
- **platform-objects `action-state-visibility.test.ts`**: Restore stays
offered on a membership-ended row (the server decides).
- **dogfood `api-key-data-door-session-required.dogfood.test.ts`**,
through the real HTTP door: the `restore_api_key` request read off the
REGISTERED schema (method, target, `bodyExtra`), sent by the member's
session and by the platform administrator's session, each refused 403
`PERMISSION_DENIED`, row unchanged, key still `API_KEY_REVOKED`;
control: the administrator's same request restores a reset-revoked key
(200, key works).
- Reason-only clear (seat review): a user-context write of `{
revoked_reason: null }` with no `revoked` key is refused 403
`PERMISSION_DENIED` to the owner and both platform administrators (by
grant and by the `OS_PLATFORM_OWNER_EMAIL` anchor), on the engine and on
the data door, so no restore follows it (head `a3727aaa`). The refuser
is the ADR-0092 D2 guard; with the whitelist widened, the readonly strip
still refuses it.
## Acceptance notes
- **Accepted-deviation candidate, for the contract review:** a platform
administrator cannot restore a membership-ended key, unlike
`admin_password_reset`, where they can. Reason: a non-member's key
re-armed by anyone in a user context reopens the hole the revocation
closed, and an environment's administrator is exactly the restorer
cloud#3423 named. A system-context write still re-admits a key, so a
host that re-adds the member can restore it deliberately. The
admin-exemption alternative is measured below (it turns exactly the
three admin legs red).
- Restore stays visible on such a row. The list views do not project
`revoked_reason`, so a `visible` predicate on it would be unreliable;
the server refusal says why.
- The cloud half is a separate card after a pin round: cloud's
`environment-identity-revocation-plugin.ts` writes `revoked_reason:
'organization_membership_ended'` beside `revoked: true` for the
`membership_ended` reason. Option membership is never enforced on a
readonly select (objectql `record-validator.ts`, the open-vocabulary
ruling), so that write would be stored even on today's pin; the refusal
needs this release of plugin-auth.
## Verification
<details><summary>Ablations (each mutation landed on disk, run, restored
to the HEAD blob)</summary>
- Rule 1 removed (`if (false && …)`), engine level: 5 of 19 red: owner,
admin by grant, admin by email anchor, re-revoke, admin batch. Restored,
`git diff HEAD` empty.
- Admin exemption added (the `admin_password_reset` pattern), engine
level: 3 of 19 red: admin by grant, admin by email anchor, admin batch;
the owner legs stay green.
- Admin exemption, HTTP level (plugin-auth rebuilt, marker proven in
`dist/`, then the dogfood file): the platform administrator's leg red,
`expected 200 to be 403`, the row restored and the reason cleared.
Restore leg: rebuilt, marker proven absent from `dist/`, `git diff HEAD`
empty.
- Core text arm removed: 1 of 23 red (the membership-ended text pin).
Restored.
</details>
<details><summary>Local runs</summary>
All at head `eaefa983d` (objectstack-ai/objectstack), after the last
commit.
- Package suites, under the verify lock: `@objectstack/plugin-auth` 240
files / 4257 passed, 10 skipped; `@objectstack/core` 136 / 3147 passed;
`@objectstack/platform-objects` 120 / 1751 passed.
- `typecheck` (each package's own script, test-typecheck included):
`core`, `platform-objects`, `plugin-auth`, `dogfood`, all Done.
- Dogfood, one file:
`api-key-data-door-session-required.dogfood.test.ts` 10 / 10 passed
(showcase closure built first). The full dogfood battery is not owed: no
`!` in the title and no `(narrowing)` arm.
- `check:i18n` for platform-objects: in sync (20 bundles) after
`--write`, which rewrote only the en bundle.
- `dispatch-gates --commands`: 75 families derived from the merge base
`db5023141`; `--ran` reconciliation with recorded exit codes: 74 run
with exit 0, 1 NOT MEASURED: `check:dual-build-cjs-loads`, PREREQUISITE
NOT MET (exit 3; it reads every package's `dist/`, and only the
plugin-auth, dogfood and cli closures were built here). CI's `Lint &
Repo Gates` runs it on a full build.
- `check-plugin-teardown-shape --self-test` first exited 3 because its
positive-control fixture commit was outside this shallow clone; after
fetching that one commit it passed (48 cases).
- Not run locally, owned by CI: the repo-wide `pnpm lint` and
`test:repo`.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (CI-owned run).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: not touched.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QUs3BrzAC1gmXvDdmDaVk6)_
<!-- signed: repo:cloud#3 · os-zhuang · session_01QUs3BvRcDHXqRU4TSVE4u6
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent eb92a48 commit f6a429b
17 files changed
Lines changed: 438 additions & 61 deletions
File tree
- .changeset
- packages
- core/src/security
- platform-objects/src
- apps/translations
- identity
- plugins/plugin-auth/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
351 | 351 | | |
352 | 352 | | |
353 | 353 | | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
354 | 371 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
315 | 315 | | |
316 | 316 | | |
317 | 317 | | |
318 | | - | |
319 | | - | |
320 | | - | |
321 | | - | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
322 | 325 | | |
323 | 326 | | |
324 | 327 | | |
325 | 328 | | |
326 | | - | |
327 | | - | |
328 | | - | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
329 | 336 | | |
330 | 337 | | |
331 | 338 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1160 | 1160 | | |
1161 | 1161 | | |
1162 | 1162 | | |
1163 | | - | |
| 1163 | + | |
1164 | 1164 | | |
1165 | | - | |
| 1165 | + | |
| 1166 | + | |
1166 | 1167 | | |
1167 | 1168 | | |
1168 | 1169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1160 | 1160 | | |
1161 | 1161 | | |
1162 | 1162 | | |
1163 | | - | |
| 1163 | + | |
1164 | 1164 | | |
1165 | | - | |
| 1165 | + | |
| 1166 | + | |
1166 | 1167 | | |
1167 | 1168 | | |
1168 | 1169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1160 | 1160 | | |
1161 | 1161 | | |
1162 | 1162 | | |
1163 | | - | |
| 1163 | + | |
1164 | 1164 | | |
1165 | | - | |
| 1165 | + | |
| 1166 | + | |
1166 | 1167 | | |
1167 | 1168 | | |
1168 | 1169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1160 | 1160 | | |
1161 | 1161 | | |
1162 | 1162 | | |
1163 | | - | |
| 1163 | + | |
1164 | 1164 | | |
1165 | | - | |
| 1165 | + | |
| 1166 | + | |
1166 | 1167 | | |
1167 | 1168 | | |
1168 | 1169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1160 | 1160 | | |
1161 | 1161 | | |
1162 | 1162 | | |
1163 | | - | |
| 1163 | + | |
1164 | 1164 | | |
1165 | | - | |
| 1165 | + | |
| 1166 | + | |
1166 | 1167 | | |
1167 | 1168 | | |
1168 | 1169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1160 | 1160 | | |
1161 | 1161 | | |
1162 | 1162 | | |
1163 | | - | |
| 1163 | + | |
1164 | 1164 | | |
1165 | | - | |
| 1165 | + | |
| 1166 | + | |
1166 | 1167 | | |
1167 | 1168 | | |
1168 | 1169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1160 | 1160 | | |
1161 | 1161 | | |
1162 | 1162 | | |
1163 | | - | |
| 1163 | + | |
1164 | 1164 | | |
1165 | | - | |
| 1165 | + | |
| 1166 | + | |
1166 | 1167 | | |
1167 | 1168 | | |
1168 | 1169 | | |
| |||
0 commit comments