mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 17:46:16 -10:00
feat(managed-agent): persist workspace actor roles and session owners (#13544)
* feat(managed-agent): persist workspace actor roles and session owners
Slice B of #13535 (storage only, behavior-invisible):
- V48 migration replaces managed_workspace_access.can_read/can_create
with a single role column (READER/OPERATOR/OWNER, CHECK-enforced),
backfilling can_create -> OPERATOR and can_read-only -> READER, and
adds managed_agent_session.owner_actor_key backfilled from
creator_actor_key.
- WorkspaceAccess becomes NONE/READER/OPERATOR/OWNER (ordered, with an
atLeast helper); NONE remains the no-row decision, never storable.
- Every grant reader (canRead, findReadable, listReadable,
readableDefault, resolveForCreation, authorizePassiveAttachment, the
cwd-change registry-facts join, the listSessions EXISTS filter) keeps
its current truth table, re-derived from role.
- insertSession writes owner_actor_key = creator on both bound and
legacy creation paths; anonymous legacy creation leaves both NULL.
- Test fixtures move to the role column; revocations that previously
zeroed booleans now delete the grant row (the honest no-access
equivalent under the CHECK); a (FALSE,TRUE) fixture state that role
ordering makes unrepresentable becomes a grant-row deletion.
- New tests: V47->V48 migration shape (backfill, owner:=creator, CHECK
rejects NONE/invalid, post-V48 store-path creation writes owner) and
session-owner store test (bound/legacy owner write, anonymous NULL).
The full managed-agent-server suite differs from baseline only in the
new tests; ManagedSessionStoreIntegrationTest#holdsRestorePages
InsideThePerPageByteBudget errors identically on unmodified HEAD
(verified in a clean worktree at ac497aeed9).
* fix(managed-agent): drop unreadable workspace grants before the V48 backfill
A (can_read=FALSE) row grants nothing under the booleans, but the role relabel would have given it READER (or OPERATOR with can_create). Dropping such rows keeps the migration a pure relabel for every reachable grant; pins the drop for (FALSE,TRUE) and (FALSE,FALSE) shapes in the migration test.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): renumber roles migration to V51 and drop its default
#13260 took V48-V50 while this branch sat in review; the roles
migration therefore renumbers to V51. W1c's chain never touches
managed_workspace_access or managed_agent_session, so the renumber is
a pure rename for its backfill targets.
The triage finding on #13544 also lands: role drops its 'READER'
default so an INSERT omitting it fails loudly (violates NOT NULL), as
omitting a boolean did. The column arrives NULL, the backfill fills
every existing row, and MODIFY COLUMN tightens it to NOT NULL right
after — H2 cannot backfill a no-default NOT NULL ADD COLUMN on a
non-empty table.
Reconcile main-side fixtures added by #13543 and #13260 against the
old boolean columns (SurfaceAdmissionAcceptanceTest grant helper and
artifact fixture, ManagedAgentApiContractTest migration-refusal
fixture, ManagedActionsTest migration-fence fixture,
WorkspaceMigrationMySqlIT admission fixtures): TRUE/TRUE -> OPERATOR,
TRUE/FALSE -> READER; no admission-family assertion changes.
ManagedWorkspaceRolesMigrationTest pins the omission-fails behaviour,
and the design docs quote the new SQL shape in both languages.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): reconcile mysql-profile ITs with the V51 shape
The mysql-integration profile runs history-seeded suites the H2 gate
cannot see, and the MariaDB lane caught two:
- WorkspaceSessionRetentionMySqlIT seeds its grant row at V31, where
the columns are still the booleans; the slice-B role INSERT is
grammar-invalid there. The seed returns to (TRUE, TRUE), which the
V51 backfill maps to OPERATOR on upgrade — downgrade-path
assertions unchanged.
- WorkspaceMigrationMySqlIT pins the post-V47 versions W1c adds as
exactly 48-50; V51 is the fourth in this tree, so the
contains-exactly list gains "51".
Verified against a local MariaDB 10.11.19 (the lane runs 10.11.18):
WorkspaceMigrationMySqlIT 5/5 and WorkspaceSessionRetentionMySqlIT
16/16 under -Pmysql-integration verify, BUILD SUCCESS.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): move the failover E2E fixture to the role column
run-managed-agent-server-e2e.ts seeds the bound-session workspace
grant with the boolean columns; once the schema migrates to V51 the
INSERT dies with ERROR 1054 (unknown column 'can_read') and every
failover scenario in the hosted MySQL lane fails before the fixture
spins up. The grant maps to OPERATOR, matching the (TRUE, TRUE)
booleans it replaces and the backfill's relabel.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): make the role CHECK byte-exact and unmask its tests
Review on #13544 found two gaps in V51's stored guarantee, both real
on their evidence and verified independently here:
- utf8mb4 comparisons are PAD SPACE, so the IN-list CHECK accepted and
preserved 'READER ' — a value every WorkspaceAccess.valueOf reader
rejects, so one out-of-band provisioning slip turns an actor's
discovery routes into 400s. The CHECK now pairs each name with its
exact length ('READER' = 6, 'OPERATOR' = 8, 'OWNER' = 5). An
anchored REGEXP solves the padding too, but its backslash arrives
rewritten on MySQL (string literals escape it, H2's do not), so the
predicate deliberately carries none; H2 2.4.240 and MariaDB 10.11.19
both prove it accepts exactly the three canonical names and rejects
padded, lowercase and unknown values.
- The negative migration assertions reused the seeded OPERATOR actor
key, so each collided on the primary key and DuplicateKeyException
(a DataIntegrityViolationException) satisfied them regardless of
the CHECK's presence or the role default's absence. Every negative
case gets a fresh, unseeded actor key, and the padded-value case
joins the set so the byte-exactness regression is pinned in H2.
ManagedWorkspaceRolesMySqlIT carries the same matrix on a real engine
in CI (canonical stored untouched, padded and unknown rejected with
nothing persisted).
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): renumber the roles migration to V52 and move L3 test grants to role
L3 (#13354) took V51 for workspace_session_lifecycle, colliding with this branch's roles migration at merge time (the merged jar failed startup on a duplicate version 51). The roles migration becomes V52; the design docs (EN and zh-CN), the roles migration-shape and retention tests, and WorkspaceMigrationMySqlIT's post-V47 pin (now 48-52) follow. The grants L3's tests still write as booleans move to role with the semantics the storage keeps: fixture INSERTs seed OPERATOR, the create-flip updates use READER/OPERATOR, and the revoke-by-boolean case becomes the delete role semantics make of it.
Locally the Flyway uniqueness gate passes (52 migrations, unique) and the full managed-agent-server H2 suite runs 1370/0/0, the same count the real-stack run reported with 54 merge-shaped errors.
* fix(managed-agent): renumber the roles migration to V53 and reconcile #13163's fixtures
#13163 took V52 for the mutation-attempt sequence while this branch
carried V52 for the roles migration; the roles migration moves to V53.
#13163's own test fixtures still write the dropped boolean columns and
now sit on the V53 schema, so they move to the shapes the storage keeps:
the StorageGuard revoke/restore flips use READER/OPERATOR, the
cold-cancel mutability ladder uses OPERATOR/READER and turns its
lose-read case into the grant-row deletion, the admission scenario's
create-revoke uses READER, and WorkspaceMigrationMySqlIT pins the
post-V47 versions as 48-53.
Locally the flyway gate passes (53 migrations, all versions unique), the
full managed-agent-server H2 suite runs 1394/0/0, and the four key
integration suites run 27/0/0 on MariaDB 10.11.19 (Roles, Migration,
Retention, Close).
Co-authored-by: Qwen-Coder <[email protected]>
* fix(managed-agent): make the roles IT portable across engines and drop its schema
The real-engine verification round on #13544 found three test-side
items in the new ManagedWorkspaceRolesMySqlIT. The CHECK-violation
shape reaches the runtime as different Spring exceptions per engine —
MySQL 8.4 reports 3819/HY000, which Spring 6.2 cannot map, so it
surfaces as UncategorizedSQLException while MariaDB's 4025/23000 maps
to DataIntegrityViolationException — and the negative matrix asserted
the latter, failing any MySQL run. The assertion now talks
DataAccessException plus the constraint name, matching the engines the
slice runs on and keeping the mutation coverage mutants cannot dodge.
The class javadoc and the H2-side comment now name the shipped
length-guarded CHECK instead of the shape's earlier REGEXP phrasing,
and the class drops its per-test schema like its neighbouring ITs.
Verified on MariaDB (fresh schema per run, 1/1) and H2 (migration test).
Co-authored-by: Qwen-Coder <[email protected]>
---------
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
509c310fd4
commit
fbde5cf00e
48 files changed
+674
-255
No files matched your search
@@ -13,7 +13,7 @@ Verified at `main` = `ac497aeed9` (one commit past the `b585508733` baseline the
|
||||
issue names; the delta is a TUI change outside this module).
|
||||
|
||||
Status: slice A (#13543) lands the registry and its gates over today's
|
||||
admission. The V48 storage in D2/D3 lands with slice B (#13544) and the
|
||||
admission. The V53 storage in D2/D3 lands with slice B (#13544) and the
|
||||
enforcement in D4/D7 with slice C (#13545); until those merge, their sections
|
||||
describe planned changes, not the tree.
|
||||
|
||||
@@ -111,28 +111,46 @@ of actors the server cannot enumerate; the blocking behaviours are defined by
|
||||
who shares the _Workspace binding_. Not tenant-keyed: it cannot express
|
||||
"reader on A, operator on B".
|
||||
|
||||
Concretely, migration V48 replaces the two booleans with one column:
|
||||
Concretely, migration V53 replaces the two booleans with one column:
|
||||
|
||||
```sql
|
||||
ALTER TABLE managed_workspace_access
|
||||
ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'READER';
|
||||
ADD COLUMN role VARCHAR(16) NULL;
|
||||
-- A row without can_read grants nothing today; keeping it would gain
|
||||
-- READER (or OPERATOR, for a can_create row) through the backfill.
|
||||
DELETE FROM managed_workspace_access WHERE can_read = FALSE;
|
||||
UPDATE managed_workspace_access
|
||||
SET role = CASE WHEN can_create THEN 'OPERATOR' ELSE 'READER' END;
|
||||
ALTER TABLE managed_workspace_access
|
||||
MODIFY COLUMN role VARCHAR(16) NOT NULL;
|
||||
ALTER TABLE managed_workspace_access DROP COLUMN can_read;
|
||||
ALTER TABLE managed_workspace_access DROP COLUMN can_create;
|
||||
ALTER TABLE managed_workspace_access
|
||||
ADD CONSTRAINT managed_workspace_access_role
|
||||
CHECK (role IN ('READER', 'OPERATOR', 'OWNER'));
|
||||
CHECK ((role = 'READER' AND CHAR_LENGTH(role) = 6)
|
||||
OR (role = 'OPERATOR' AND CHAR_LENGTH(role) = 8)
|
||||
OR (role = 'OWNER' AND CHAR_LENGTH(role) = 5));
|
||||
```
|
||||
|
||||
The equality-plus-length form replaces an `IN` list on purpose:
|
||||
utf8mb4 comparisons ignore trailing spaces (PAD SPACE), so an `IN` list
|
||||
would store `READER ` — a value the enum parser then rejects at read
|
||||
time, turning one out-of-band provisioning slip into 400s on that
|
||||
actor's discovery routes. An anchored REGEXP would solve that in
|
||||
isolation, but its backslash gets rewritten on the way in (MySQL string
|
||||
literals treat it as an escape, H2's do not), so the predicate carries
|
||||
none: per-name equality plus its exact length keeps the stored values
|
||||
byte-identical to the enum names.
|
||||
|
||||
The compound shapes are split into per-action statements, matching the
|
||||
in-repo migration precedent (V7, V12, V24, V40); dropping unreadable rows
|
||||
before the backfill is what makes the change purely a relabelling for every
|
||||
reachable grant row. `role` is the single stored vocabulary; no
|
||||
dual-write. The `WorkspaceAccess`
|
||||
dual-write. `role` carries no default either: the column arrives NULL so
|
||||
the backfill can fill every existing row, `MODIFY COLUMN` tightens it to NOT
|
||||
NULL immediately after, and from then an INSERT omitting it violates NOT
|
||||
NULL, as omitting a boolean did before, so out-of-band provisioning SQL
|
||||
fails loudly instead of silently granting READER. The `WorkspaceAccess`
|
||||
enum becomes `NONE / READER / OPERATOR / OWNER` (READ→READER,
|
||||
CREATE→OPERATOR); `OWNER` implies `OPERATOR` implies `READER`. Every store
|
||||
reader (`canRead`, `findReadable`, `listReadable`, `canCreateSession`,
|
||||
@@ -148,7 +166,7 @@ appears in this slice (section 7).
|
||||
|
||||
### D3 — the Session record keeps an owner, defaulting to its creator
|
||||
|
||||
V48 also adds `managed_agent_session.owner_actor_key VARBINARY(2048) NULL`
|
||||
V53 also adds `managed_agent_session.owner_actor_key VARBINARY(2048) NULL`
|
||||
and backfills it from `creator_actor_key`. Three identity facts stay
|
||||
deliberately separate:
|
||||
|
||||
@@ -163,7 +181,7 @@ deliberately separate:
|
||||
|
||||
`managed_workspace_create_command` remains what it is — an idempotency-command
|
||||
record whose `actor_id` belongs to the idempotency domain, not to
|
||||
authorization. After V48 the authorization reads of it (the NULL-creator
|
||||
authorization. After V53 the authorization reads of it (the NULL-creator
|
||||
fallbacks in `requireOwner` / `requireWorkspaceCreator`) survive only for
|
||||
sessions created before V40; new sessions always carry creator and owner.
|
||||
|
||||
@@ -268,7 +286,7 @@ file-scope disjointness, not topic:
|
||||
| Slice | Content | Touches |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A — registry + gate (R2 first)** | `SurfaceRegistry` over today's rules, correspondence gate, acceptance probes, parity assertions, bilingual route matrix in this doc | new test-tree files only: `api/SurfaceRegistry.java`, the gate and its negative twin, the acceptance probes; no production edits |
|
||||
| **B — role storage (R1 storage)** | V48 migration + backfill, `WorkspaceAccess` rename, registry store reads re-derivd from `role`, `owner_actor_key` column + write at creation, fixture INSERT updates (~24 sites), migration-shape tests | `store/**`, `runtime-broker` enum, `db/migration`, test fixtures; no admission-decision change |
|
||||
| **B — role storage (R1 storage)** | V53 migration + backfill, `WorkspaceAccess` rename, registry store reads re-derivd from `role`, `owner_actor_key` column + write at creation, fixture INSERT updates (~24 sites), migration-shape tests | `store/**`, `runtime-broker` enum, `db/migration`, test fixtures; no admission-decision change |
|
||||
| **C — enforcement (R1)** | the three creator helpers re-pointed at role/owner, refusal-code normalisation, Action respond opens to OPERATOR, WebShell capabilities by role, registry rule flips, probe expectation flips, contract v1.34 + OpenAPI text, contract-test updates | `service/**`, `store/**` checks, controllers, contract, A's enum + tests |
|
||||
|
||||
A ∥ B is safe: disjoint files (A adds; B edits store-side). C is serial after
|
||||
@@ -278,8 +296,8 @@ raced. C closes #13535; A and B reference it.
|
||||
|
||||
## 5. Migration and compatibility
|
||||
|
||||
- V48 follows the established single-version doctrine (stop old servers
|
||||
before the migration; no `outOfOrder`). Taking V48 requires renumbering any
|
||||
- V53 follows the established single-version doctrine (stop old servers
|
||||
before the migration; no `outOfOrder`). Taking V53 requires renumbering any
|
||||
open branch's later migration; `scripts/check-flyway-migrations.js` already
|
||||
gates numbering across both migration locations.
|
||||
- Contract v1.34 records: the role vocabulary, OPERATOR admission for the
|
||||
@@ -320,7 +338,7 @@ Same as the issue's, plus the explicit deferrals named there:
|
||||
untyped, and on a registered route nothing mounts — a committed negative
|
||||
test keeps proving both; probes pin today's statuses per rule class on
|
||||
public, WebShell and internal routes.
|
||||
- Slice B: migration-shape test applying V48 over V47 fixtures asserts the
|
||||
- Slice B: migration-shape test applying V53 over V47 fixtures asserts the
|
||||
backfill (can_create → OPERATOR, can_read-only → READER, owner := creator);
|
||||
the full existing suite stays green untouched except fixture INSERTs —
|
||||
that is the behaviour-invisibility proof.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
核实基点:`main` = `ac497aeed9`(比 issue 点名的 `b585508733` 基线新一个提交;增量是本模块之外的 TUI 改动)。
|
||||
|
||||
状态:切片 A(#13543)按今天的准入落地注册表及其门禁。D2/D3 中的 V48 存储随切片 B(#13544)落地,D4/D7 中的强制执行随切片 C(#13545)落地;在它们合入之前,相应各节描述的是计划中的改动,而不是树上的现状。
|
||||
状态:切片 A(#13543)按今天的准入落地注册表及其门禁。D2/D3 中的 V53 存储随切片 B(#13544)落地,D4/D7 中的强制执行随切片 C(#13545)落地;在它们合入之前,相应各节描述的是计划中的改动,而不是树上的现状。
|
||||
|
||||
## 1. 问题
|
||||
|
||||
@@ -52,36 +52,42 @@ workspace registry/access 行没有任何生产置备路径 —— 今天只有
|
||||
|
||||
Q4 的答案:一张授权表,按 `(tenant_id, workspace_id, actor_id)` 键 —— 即已经存在、且已在每条绑定路由读路径上的 `managed_workspace_access` 表。不采用网关声明:`AuthenticatedTenantActor` 契约保持 tenant + actor,按 Workspace 的授权塞不进声明集。不采用按 Session 键:那会把行数乘以 sessions × actors,且每次创建都要为一组服务器无法枚举的 actor 做扇出插入;被阻塞的行为恰恰由谁共享 _Workspace 绑定_ 定义。不采用按租户键:那无法表达「A 上是 reader、B 上是 operator」。
|
||||
|
||||
具体地,迁移 V48 用一列取代两个布尔:
|
||||
具体地,迁移 V53 用一列取代两个布尔:
|
||||
|
||||
```sql
|
||||
ALTER TABLE managed_workspace_access
|
||||
ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'READER';
|
||||
ADD COLUMN role VARCHAR(16) NULL;
|
||||
-- 没有 can_read 的行今天不授予任何东西;保留它会经回填拿到 READER
|
||||
-- (若是 can_create 行则拿到 OPERATOR)。
|
||||
DELETE FROM managed_workspace_access WHERE can_read = FALSE;
|
||||
UPDATE managed_workspace_access
|
||||
SET role = CASE WHEN can_create THEN 'OPERATOR' ELSE 'READER' END;
|
||||
ALTER TABLE managed_workspace_access
|
||||
MODIFY COLUMN role VARCHAR(16) NOT NULL;
|
||||
ALTER TABLE managed_workspace_access DROP COLUMN can_read;
|
||||
ALTER TABLE managed_workspace_access DROP COLUMN can_create;
|
||||
ALTER TABLE managed_workspace_access
|
||||
ADD CONSTRAINT managed_workspace_access_role
|
||||
CHECK (role IN ('READER', 'OPERATOR', 'OWNER'));
|
||||
CHECK ((role = 'READER' AND CHAR_LENGTH(role) = 6)
|
||||
OR (role = 'OPERATOR' AND CHAR_LENGTH(role) = 8)
|
||||
OR (role = 'OWNER' AND CHAR_LENGTH(role) = 5));
|
||||
```
|
||||
|
||||
复合语句按仓内迁移先例(V7、V12、V24、V40)拆成每动作一条;先删除无可读行再回填,才使这次改动对每一个可达授权行都只是改名。`role` 是唯一存储词表;不双写。`WorkspaceAccess` 枚举变为 `NONE / READER / OPERATOR / OWNER`(READ→READER、CREATE→OPERATOR);`OWNER` 蕴含 `OPERATOR` 蕴含 `READER`。每个 store 读取点(`canRead`、`findReadable`、`listReadable`、`canCreateSession`、`resolveForCreation`、`authorizePassiveAttachment`、SSE 读授权复查、list 路由的 SQL 过滤)保持当前判定不变,布尔由 `role` 重新推导 —— 这是一次行为不可见的内部改动,由现有测试套件钉住。`NONE` 不可存储(CHECK 排除它);它保留为「无行」的领域值。
|
||||
约束刻意用「等值 + 精确长度」而不是 `IN` 列表:utf8mb4 比较忽略尾随空格(PAD SPACE),`IN` 列表会收下 `READER ` —— 读路径的枚举解析又拒绝它,于是一次带外置备笔误就会让该 actor 的发现路由全数返回 400。锚定 REGEXP 孤立地看也能做到,但它的反斜杠会在入库路上被改写(MySQL 字符串字面量把反斜杠当转义,H2 不当),所以谓词里干脆不放反斜杠:逐名等值加精确长度,让落库值与枚举名保持逐字节一致。
|
||||
|
||||
复合语句按仓内迁移先例(V7、V12、V24、V40)拆成每动作一条;先删除无可读行再回填,才使这次改动对每一个可达授权行都只是改名。`role` 是唯一存储词表;不双写。`role` 也不带默认值:列先以 NULL 到来让回填覆盖全部存量行,随后 `MODIFY COLUMN` 收紧为 NOT NULL;此后省略它的 INSERT 违反 NOT NULL,与省略布尔列时相同,带外置备 SQL 因此响亮失败而不是静默授予 READER。`WorkspaceAccess` 枚举变为 `NONE / READER / OPERATOR / OWNER`(READ→READER、CREATE→OPERATOR);`OWNER` 蕴含 `OPERATOR` 蕴含 `READER`。每个 store 读取点(`canRead`、`findReadable`、`listReadable`、`canCreateSession`、`resolveForCreation`、`authorizePassiveAttachment`、SSE 读授权复查、list 路由的 SQL 过滤)保持当前判定不变,布尔由 `role` 重新推导 —— 这是一次行为不可见的内部改动,由现有测试套件钉住。`NONE` 不可存储(CHECK 排除它);它保留为「无行」的领域值。
|
||||
|
||||
授权置备保持带外,与今天两个布尔的置备方式一致:fixture/部署 SQL 写行;本切片不出现 HTTP 授权管理路由(第 7 节)。
|
||||
|
||||
### D3 —— Session 记录持有 owner,默认为其创建者
|
||||
|
||||
V48 同时新增 `managed_agent_session.owner_actor_key VARBINARY(2048) NULL`,并从 `creator_actor_key` 回填。三个身份事实刻意分开:
|
||||
V53 同时新增 `managed_agent_session.owner_actor_key VARBINARY(2048) NULL`,并从 `creator_actor_key` 回填。三个身份事实刻意分开:
|
||||
|
||||
- **记录**(Session 行)持有 owner —— 一个 actor,初始即创建者;
|
||||
- **绑定**(workspace 授权行)持该租户每个 actor 在各 Workspace 上可做什么;
|
||||
- **角色**是准入路径查询的词表 —— 对 Session 级校验,Session 的 owner 对该 Session 以 OWNER 权利行事,与其 workspace 授权无关,这使今天的创建者行为被原样保留。
|
||||
|
||||
`managed_workspace_create_command` 保持其本职 —— 幂等命令记录,其 `actor_id` 属于幂等域而非授权。V48 之后对它的授权性读取(`requireOwner` / `requireWorkspaceCreator` 中 NULL 创建者回退)只为 V40 之前创建的会话保留;新会话总是同时带 creator 与 owner。
|
||||
`managed_workspace_create_command` 保持其本职 —— 幂等命令记录,其 `actor_id` 属于幂等域而非授权。V53 之后对它的授权性读取(`requireOwner` / `requireWorkspaceCreator` 中 NULL 创建者回退)只为 V40 之前创建的会话保留;新会话总是同时带 creator 与 owner。
|
||||
|
||||
owner 的更新路径(移交命令)是建在此列之上的后续切片;本切片给出移交所需的词表与存储,并把每个创建者校验改指 owner(第 7 节)。
|
||||
|
||||
@@ -134,14 +140,14 @@ owner 的更新路径(移交命令)是建在此列之上的后续切片;
|
||||
| 切片 | 内容 | 改动范围 |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **A —— 注册表 + 门禁(先行 R2)** | 按今天规则建 `SurfaceRegistry`、对账门禁、验收探针、奇偶断言、本文档的双语路由矩阵 | 全部为测试树新文件:`api/SurfaceRegistry.java`、门禁及其反向孪生、验收探针;不改生产代码 |
|
||||
| **B —— 角色存储(R1 存储)** | V48 迁移与回填、`WorkspaceAccess` 改名、注册表 store 读取改由 `role` 推导、`owner_actor_key` 列与创建时写入、fixture INSERT 更新(约 24 处)、迁移形态测试 | `store/**`、runtime-broker 枚举、`db/migration`、测试 fixture;不改任何准入判定 |
|
||||
| **B —— 角色存储(R1 存储)** | V53 迁移与回填、`WorkspaceAccess` 改名、注册表 store 读取改由 `role` 推导、`owner_actor_key` 列与创建时写入、fixture INSERT 更新(约 24 处)、迁移形态测试 | `store/**`、runtime-broker 枚举、`db/migration`、测试 fixture;不改任何准入判定 |
|
||||
| **C —— 强制执行(R1)** | 三个创建者辅助方法改指角色/owner、拒绝码归一、Action 回答向 OPERATOR 开放、按角色的 WebShell 能力广告、注册表规则翻转、探针期望翻转、契约 v1.34 与 OpenAPI 文本、契约测试更新 | `service/**`、`store/**` 校验点、controller、契约、A 的枚举与测试 |
|
||||
|
||||
A ∥ B 是安全的:文件不相交(A 纯新增;B 改 store 侧)。C 必须在两者合入后串行,因为它同时改写 A 的注册表条目与 B 的辅助方法 —— 这是唯一真实的阻塞依赖,所以被排序而不是被抢跑。C 关闭 #13535;A 与 B 引用它。
|
||||
|
||||
## 5. 迁移与兼容
|
||||
|
||||
- V48 沿用既有单版本纪律(迁移前停掉旧版本服务器;不用 `outOfOrder`)。占用 V48 意味着任何开放分支上的后续迁移号要重排;`scripts/check-flyway-migrations.js` 已跨两个迁移目录门禁编号。
|
||||
- V53 沿用既有单版本纪律(迁移前停掉旧版本服务器;不用 `outOfOrder`)。占用 V53 意味着任何开放分支上的后续迁移号要重排;`scripts/check-flyway-migrations.js` 已跨两个迁移目录门禁编号。
|
||||
- 契约 v1.34 记录:角色词表、submitter 族与 Action 族的 OPERATOR 准入、基于 owner 的生命周期、submitter 族拒绝语义 409 `workspace_unavailable` → 403 `session_operation_forbidden` 的变化、以及按角色的能力广告。
|
||||
- 客户端可观察的拒绝码变化:绑定 Session 上的非创建者提交(409 → 403);Action 回答对非创建者的 OPERATOR 由拒绝变为成功。其余对调用方保持不变。
|
||||
- 写 `can_read`/`can_create` 的测试 fixture 在切片 B 改写 `role`;曾考虑生成列方案,为保持单一事实源与 H2/MySQL 简单对齐而放弃。
|
||||
@@ -158,7 +164,7 @@ A ∥ B 是安全的:文件不相交(A 纯新增;B 改 store 侧)。C
|
||||
## 7. 验证计划
|
||||
|
||||
- 切片 A:对账门禁会因未注册路由(带方法或不带方法的映射)失败,也会因无人挂载的注册条目失败 —— 一个已提交的反向测试持续证明这两者;探针按规则类在公开、WebShell 与内部路由上钉住今天的状态码。
|
||||
- 切片 B:迁移形态测试在 V47 fixture 之上应用 V48,断言回填(can_create → OPERATOR,仅 can_read → READER,owner := creator);现有整套测试除 fixture INSERT 外原样全绿 —— 这就是行为不可见的证明。
|
||||
- 切片 B:迁移形态测试在 V47 fixture 之上应用 V53,断言回填(can_create → OPERATOR,仅 can_read → READER,owner := creator);现有整套测试除 fixture INSERT 外原样全绿 —— 这就是行为不可见的证明。
|
||||
- 切片 C:更新后的探针与契约测试钉住新矩阵;定向测试:第二个 OPERATOR 在两个面上回答待答审批,OPERATOR 提交/取消/改名/改 cwd,owner 生命周期不变,可读陌生人保持 404,角色撤销按原窗口翻转 SSE 准入;MySQL 奇偶走 failsafe profile(`mysql-integration`、`hosted-harness-mysql`,视 fixture 允许)。
|
||||
- 跨面奇偶断言两次:结构性(注册表 能力→规则类)与行为性(孪生探针)。
|
||||
|
||||
|
||||
+3
-3
@@ -905,9 +905,9 @@ public class ManagedAgentService {
|
||||
return false;
|
||||
}
|
||||
// The caller is the Session's creator, so this reads the creator's
|
||||
// grant row, as the execution authority's join does: can_read (the
|
||||
// join's own filter) and can_create, on a registry whose state is
|
||||
// ACTIVE.
|
||||
// grant row, as the execution authority's join does: READER or
|
||||
// above (the join's own filter) and OPERATOR for creation, on a
|
||||
// registry whose state is ACTIVE.
|
||||
ManagedWorkspaceRegistry.WorkspaceSummary summary =
|
||||
workspaces.findReadable(session.tenantId(), actorId,
|
||||
session.workspace().getWorkspaceId());
|
||||
|
||||
+9
-10
@@ -400,6 +400,8 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
String turnId = input.isEmpty() ? null : publicId("turn");
|
||||
String promptId = input.isEmpty() ? null
|
||||
: UUID.randomUUID().toString();
|
||||
byte[] actorKey = actorId == null ? null
|
||||
: ManagedWorkspaceRegistry.actorKey(tenantId, actorId);
|
||||
jdbc.update("INSERT INTO managed_agent_session (tenant_id,"
|
||||
+ " session_id, agent_id, agent_revision, title,"
|
||||
+ " status, created_at, updated_at, workspace_id,"
|
||||
@@ -407,9 +409,9 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
+ " cwd_relative, context_config_ref,"
|
||||
+ " context_revision, workspace_config_ref,"
|
||||
+ " workspace_policy_ref, tool_profile,"
|
||||
+ " creator_actor_key) VALUES"
|
||||
+ " creator_actor_key, owner_actor_key) VALUES"
|
||||
+ " (?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?,"
|
||||
+ " ?, ?, ?, ?, ?)",
|
||||
+ " ?, ?, ?, ?, ?, ?)",
|
||||
tenantId, sessionId, agentId, agentRevision, title, now, now,
|
||||
workspace == null ? null : workspace.getWorkspaceId(),
|
||||
workspace == null ? null : workspace.getWorkspaceGeneration(),
|
||||
@@ -420,9 +422,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
resolved == null ? null : resolved.configRef(),
|
||||
resolved == null ? null : resolved.policyRef(),
|
||||
workspace == null ? null : "hosted-workspace-files/1",
|
||||
actorId == null ? null
|
||||
: ManagedWorkspaceRegistry.actorKey(tenantId,
|
||||
actorId));
|
||||
actorKey, actorKey);
|
||||
jdbc.update("INSERT INTO managed_agent_consumer_progress"
|
||||
+ " (tenant_id, session_id, consumer_name,"
|
||||
+ " covered_sequence, updated_at) VALUES"
|
||||
@@ -440,8 +440,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
+ " (tenant_id, actor_id, idempotency_key,"
|
||||
+ " request_digest, session_id, turn_id, created_at)"
|
||||
+ " VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
tenantId, ManagedWorkspaceRegistry.actorKey(tenantId,
|
||||
actorId), idempotencyKey, requestDigest,
|
||||
tenantId, actorKey, idempotencyKey, requestDigest,
|
||||
sessionId, turnId, now);
|
||||
}
|
||||
if (workspace != null) {
|
||||
@@ -1132,8 +1131,8 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
+ " WHERE s.tenant_id = ? AND s.session_id = ? AND"
|
||||
+ " r.workspace_generation = s.workspace_generation"
|
||||
+ " AND r.storage_id = s.workspace_storage_id AND"
|
||||
+ " r.state = 'ACTIVE' AND a.can_read = TRUE AND"
|
||||
+ " a.can_create = TRUE",
|
||||
+ " r.state = 'ACTIVE' AND a.role IN ('OPERATOR',"
|
||||
+ " 'OWNER')",
|
||||
(row, index) -> Boolean.TRUE, session.tenantId(),
|
||||
session.sessionId());
|
||||
return rows.size() == 1;
|
||||
@@ -1508,7 +1507,7 @@ public class ManagedAgentStore implements AgentStateStore {
|
||||
+ "managed_agent_session.workspace_id, '!')"
|
||||
+ " AS BINARY(513))"
|
||||
+ " AND wa.actor_id = ?"
|
||||
+ " AND wa.can_read = TRUE))"
|
||||
+ " AND wa.role IN ('READER', 'OPERATOR', 'OWNER')))"
|
||||
+ cursorClause
|
||||
+ " ORDER BY updated_at DESC, session_id DESC LIMIT ?",
|
||||
sessionMapper, arguments.toArray());
|
||||
|
||||
+22
-15
@@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.store;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceActor;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceRecord;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceState;
|
||||
@@ -96,7 +97,8 @@ public class ManagedWorkspaceRegistry {
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND CAST(CONCAT(workspace_id, '!') AS BINARY(513))"
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND actor_id = ? AND can_read = TRUE",
|
||||
+ " AND actor_id = ? AND role IN ('READER', 'OPERATOR',"
|
||||
+ " 'OWNER')",
|
||||
Integer.class, tenantId, workspaceId, tenantId, workspaceId,
|
||||
key).isEmpty();
|
||||
}
|
||||
@@ -105,7 +107,7 @@ public class ManagedWorkspaceRegistry {
|
||||
String actorId, String afterId, int limit) {
|
||||
byte[] key = actorKey(tenantId, actorId);
|
||||
return jdbc.query("SELECT r.workspace_id, r.display_name, r.state,"
|
||||
+ " a.can_create FROM managed_workspace_registry r"
|
||||
+ " a.role FROM managed_workspace_registry r"
|
||||
+ " JOIN managed_workspace_access a ON"
|
||||
+ " a.tenant_id = r.tenant_id"
|
||||
+ " AND a.workspace_id = r.workspace_id"
|
||||
@@ -116,7 +118,8 @@ public class ManagedWorkspaceRegistry {
|
||||
+ " WHERE r.tenant_id = ?"
|
||||
+ " AND CAST(CONCAT(r.tenant_id, '!') AS BINARY(513))"
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND a.actor_id = ? AND a.can_read = TRUE"
|
||||
+ " AND a.actor_id = ? AND a.role IN ('READER',"
|
||||
+ " 'OPERATOR', 'OWNER')"
|
||||
+ " AND (? IS NULL OR"
|
||||
+ " CAST(CONCAT(r.workspace_id, '!') AS BINARY(513)) >"
|
||||
+ " CAST(CONCAT(?, '!') AS BINARY(513)))"
|
||||
@@ -131,7 +134,7 @@ public class ManagedWorkspaceRegistry {
|
||||
byte[] key = actorKey(tenantId, actorId);
|
||||
List<WorkspaceSummary> rows = jdbc.query(
|
||||
"SELECT r.workspace_id, r.display_name, r.state,"
|
||||
+ " a.can_create FROM managed_workspace_registry r"
|
||||
+ " a.role FROM managed_workspace_registry r"
|
||||
+ " JOIN managed_workspace_access a ON"
|
||||
+ " a.tenant_id = r.tenant_id"
|
||||
+ " AND a.workspace_id = r.workspace_id"
|
||||
@@ -144,7 +147,8 @@ public class ManagedWorkspaceRegistry {
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND CAST(CONCAT(r.workspace_id, '!') AS BINARY(513))"
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND a.actor_id = ? AND a.can_read = TRUE",
|
||||
+ " AND a.actor_id = ? AND a.role IN ('READER',"
|
||||
+ " 'OPERATOR', 'OWNER')",
|
||||
(result, row) -> summary(result), tenantId, workspaceId,
|
||||
tenantId, workspaceId, key);
|
||||
return rows.isEmpty() ? null : rows.getFirst();
|
||||
@@ -176,7 +180,7 @@ public class ManagedWorkspaceRegistry {
|
||||
arguments.add(key);
|
||||
List<ReadableGrant> rows = jdbc.query(
|
||||
"SELECT r.workspace_id, r.workspace_generation,"
|
||||
+ " r.storage_id, r.state, a.can_create"
|
||||
+ " r.storage_id, r.state, a.role"
|
||||
+ " FROM managed_workspace_registry r"
|
||||
+ " JOIN managed_workspace_access a ON"
|
||||
+ " a.tenant_id = r.tenant_id"
|
||||
@@ -191,7 +195,8 @@ public class ManagedWorkspaceRegistry {
|
||||
+ " BINARY(513)) IN (" + binary + ")"
|
||||
+ " AND CAST(CONCAT(r.tenant_id, '!') AS BINARY(513))"
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND a.actor_id = ? AND a.can_read = TRUE",
|
||||
+ " AND a.actor_id = ? AND a.role IN ('READER',"
|
||||
+ " 'OPERATOR', 'OWNER')",
|
||||
(result, row) -> readableGrant(result), arguments.toArray());
|
||||
java.util.Map<String, ReadableGrant> result =
|
||||
new java.util.HashMap<>(rows.size() * 2);
|
||||
@@ -222,7 +227,8 @@ public class ManagedWorkspaceRegistry {
|
||||
String state = result.getString("state");
|
||||
return new WorkspaceSummary(result.getString("workspace_id"),
|
||||
result.getString("display_name"), state,
|
||||
result.getBoolean("can_create")
|
||||
WorkspaceAccess.valueOf(result.getString("role"))
|
||||
.atLeast(WorkspaceAccess.OPERATOR)
|
||||
&& "ACTIVE".equals(state));
|
||||
}
|
||||
|
||||
@@ -232,7 +238,8 @@ public class ManagedWorkspaceRegistry {
|
||||
return new ReadableGrant(result.getString("workspace_id"),
|
||||
result.getLong("workspace_generation"),
|
||||
result.getString("storage_id"),
|
||||
result.getBoolean("can_create")
|
||||
WorkspaceAccess.valueOf(result.getString("role"))
|
||||
.atLeast(WorkspaceAccess.OPERATOR)
|
||||
&& "ACTIVE".equals(state));
|
||||
}
|
||||
|
||||
@@ -313,24 +320,24 @@ public class ManagedWorkspaceRegistry {
|
||||
"workspace_not_found", "Workspace not found.");
|
||||
}
|
||||
List<AccessRow> access = jdbc.query(
|
||||
"SELECT can_read, can_create FROM managed_workspace_access"
|
||||
"SELECT role FROM managed_workspace_access"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ?"
|
||||
+ " AND CAST(CONCAT(tenant_id, '!') AS BINARY(513))"
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND CAST(CONCAT(workspace_id, '!') AS BINARY(513))"
|
||||
+ " = CAST(CONCAT(?, '!') AS BINARY(513))"
|
||||
+ " AND actor_id = ? FOR UPDATE",
|
||||
(result, row) -> new AccessRow(result.getBoolean("can_read"),
|
||||
result.getBoolean("can_create")), tenantId,
|
||||
(result, row) -> new AccessRow(WorkspaceAccess.valueOf(
|
||||
result.getString("role"))), tenantId,
|
||||
workspaceId, tenantId, workspaceId, key);
|
||||
if (access.isEmpty() || !access.getFirst().canRead()) {
|
||||
if (access.isEmpty() || !access.getFirst().value().canRead()) {
|
||||
if (selection == null) {
|
||||
throw workspaceRequired();
|
||||
}
|
||||
throw new ApiException(HttpStatus.NOT_FOUND,
|
||||
"workspace_not_found", "Workspace not found.");
|
||||
}
|
||||
if (!access.getFirst().canCreate()) {
|
||||
if (!access.getFirst().value().canCreate()) {
|
||||
if (selection == null) {
|
||||
throw workspaceRequired();
|
||||
}
|
||||
@@ -394,6 +401,6 @@ public class ManagedWorkspaceRegistry {
|
||||
String policyRef) {
|
||||
}
|
||||
|
||||
private record AccessRow(boolean canRead, boolean canCreate) {
|
||||
private record AccessRow(WorkspaceAccess value) {
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -7,6 +7,7 @@ import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord;
|
||||
import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRecord;
|
||||
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -141,7 +142,7 @@ public class WorkspaceExecutionStore {
|
||||
+ " r.workspace_generation, r.storage_id, r.state,"
|
||||
+ " c.tenant_id AS command_tenant, c.session_id AS command_session,"
|
||||
+ " a.tenant_id AS access_tenant, a.workspace_id AS access_workspace,"
|
||||
+ " a.can_read, a.can_create")
|
||||
+ " a.role")
|
||||
+ " FROM managed_agent_session s"
|
||||
+ (cancellation
|
||||
? " JOIN managed_agent_turn t ON t.tenant_id = s.tenant_id"
|
||||
@@ -191,7 +192,8 @@ public class WorkspaceExecutionStore {
|
||||
// verdict the terminal exit promises.
|
||||
return cancellation
|
||||
|| ("ACTIVE".equals(row.getString("state"))
|
||||
&& row.getBoolean("can_read") && row.getBoolean("can_create"))
|
||||
&& WorkspaceAccess.valueOf(row.getString("role"))
|
||||
.atLeast(WorkspaceAccess.OPERATOR))
|
||||
? 2 : 1;
|
||||
},
|
||||
session.tenantId(), session.sessionId());
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/* No default on role: an INSERT omitting it must fail loudly, matching the
|
||||
booleans it replaces (an omission used to violate NOT NULL). The column
|
||||
therefore arrives NULL, the backfill below supplies every existing row's
|
||||
value explicitly, and only then is NOT NULL set. */
|
||||
ALTER TABLE managed_workspace_access
|
||||
ADD COLUMN role VARCHAR(16) NULL;
|
||||
-- A row without can_read grants nothing today; keeping it would gain
|
||||
-- READER (or OPERATOR, for a can_create row) through the backfill.
|
||||
DELETE FROM managed_workspace_access WHERE can_read = FALSE;
|
||||
UPDATE managed_workspace_access
|
||||
SET role = CASE WHEN can_create THEN 'OPERATOR' ELSE 'READER' END;
|
||||
ALTER TABLE managed_workspace_access
|
||||
MODIFY COLUMN role VARCHAR(16) NOT NULL;
|
||||
ALTER TABLE managed_workspace_access DROP COLUMN can_read;
|
||||
ALTER TABLE managed_workspace_access DROP COLUMN can_create;
|
||||
/* utf8mb4 comparisons ignore trailing spaces (PAD SPACE), so a plain IN
|
||||
list would store 'READER ' — which the enum parser then rejects at
|
||||
read time. Equality plus a length per name pins the stored values
|
||||
byte-exact ('READER ' matches 'READER' under PAD SPACE but is 7
|
||||
chars, not 6). The predicate carries no backslash, so neither H2 nor
|
||||
MySQL's string-literal escaping can rewrite it on the way in. */
|
||||
ALTER TABLE managed_workspace_access
|
||||
ADD CONSTRAINT managed_workspace_access_role
|
||||
CHECK ((role = 'READER' AND CHAR_LENGTH(role) = 6)
|
||||
OR (role = 'OPERATOR' AND CHAR_LENGTH(role) = 8)
|
||||
OR (role = 'OWNER' AND CHAR_LENGTH(role) = 5));
|
||||
ALTER TABLE managed_agent_session
|
||||
ADD COLUMN owner_actor_key VARBINARY(2048) NULL;
|
||||
UPDATE managed_agent_session SET owner_actor_key = creator_actor_key;
|
||||
+5
-5
@@ -47,14 +47,14 @@ public final class WorkspaceBrowserFixtureMain {
|
||||
+ " 'test-hidden', 'Hidden Workspace',"
|
||||
+ " 'test-config', 'test-policy', 'ACTIVE')");
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES ('w0d-browser', 'ws-default', ?, TRUE, TRUE)",
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES ('w0d-browser', 'ws-default', ?, 'OPERATOR')",
|
||||
"browser-actor".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create)"
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES ('w0d-browser', 'ws-disabled', ?,"
|
||||
+ " TRUE, TRUE), ('w0d-browser', 'ws-hidden', ?,"
|
||||
+ " TRUE, TRUE)",
|
||||
+ " 'OPERATOR'), ('w0d-browser', 'ws-hidden', ?,"
|
||||
+ " 'OPERATOR')",
|
||||
"browser-actor".getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||
"other-actor".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_default"
|
||||
|
||||
+2
-2
@@ -69,8 +69,8 @@ public final class HostedProcessCrashFixtureMain {
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, 'storage',"
|
||||
+ " 'Workspace', ?, ?, 'ACTIVE')", tenant,
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
sessionId = spring.getBean(ManagedAgentStore.class).insertWorkspaceSessionCommand(tenant, "actor", "create",
|
||||
"sha256:" + "a".repeat(64), "qwen-code", null, null, List.of(), null,
|
||||
new WorkspaceSelection("workspace", "child")).sessionId();
|
||||
|
||||
+19
-18
@@ -598,16 +598,17 @@ class HostedPublicWorkspaceIT {
|
||||
assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename, "rename-" + workspace,
|
||||
"actor", 200).path("metadata").path("title").asText()).isEqualTo("Renamed " + workspace);
|
||||
|
||||
// A creator whose can_create grant is revoked, with the Workspace draining, keeps
|
||||
// read access but loses admission of new work: submit and rename answer
|
||||
// workspace_unavailable, and no PENDING command row is left behind. Cancelling
|
||||
// only aborts work already running, so a Turn started before the revocation is
|
||||
// still cancelled and stops without running another tool.
|
||||
// A creator whose grant drops to READER, with the Workspace draining,
|
||||
// keeps read access but loses admission of new work: submit and rename
|
||||
// answer workspace_unavailable, and no PENDING command row is left
|
||||
// behind. Cancelling only aborts work already running, so a Turn
|
||||
// started before the revocation is still cancelled and stops without
|
||||
// running another tool.
|
||||
int beforeRevokedCancel = modelRequests.size();
|
||||
String revokedTurn = request("POST", "/v1/agents/sessions/" + session + "/events", hold,
|
||||
"hold-revoked-" + workspace, "actor", 202).path("turn_id").asText();
|
||||
await().atMost(Duration.ofSeconds(35)).until(() -> modelRequests.size() > beforeRevokedCancel);
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE"
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'READER'"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?",
|
||||
tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING'"
|
||||
@@ -632,16 +633,16 @@ class HostedPublicWorkspaceIT {
|
||||
Long.class, session)).isEqualTo(executions * 2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_command"
|
||||
+ " WHERE tenant_id = ? AND command_status = 'PENDING'", Integer.class, tenant)).isZero();
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE"
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'OPERATOR'"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?",
|
||||
tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("UPDATE managed_workspace_registry SET state = 'ACTIVE'"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ?", tenant, workspace);
|
||||
|
||||
// With the running Turn settled, revoking the creator's read grant hides the
|
||||
// With the running Turn settled, revoking the creator's grant hides the
|
||||
// bound Session from every later-Turn path: submit, cancel and rename all fall
|
||||
// through to the legacy gate and answer session_not_found.
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE"
|
||||
jdbc.update("DELETE FROM managed_workspace_access"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?",
|
||||
tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(request("POST", "/v1/agents/sessions/" + session + "/events", later,
|
||||
@@ -653,8 +654,8 @@ class HostedPublicWorkspaceIT {
|
||||
assertThat(request("PATCH", "/v1/agents/sessions/" + session, rename,
|
||||
"revoked-rename-" + workspace, "actor", 404).path("error").path("code").asText())
|
||||
.isEqualTo("session_not_found");
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = TRUE"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?",
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, 'OPERATOR')",
|
||||
tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String registeredStorage = jdbc.queryForObject("SELECT storage_id FROM managed_workspace_registry"
|
||||
@@ -702,9 +703,9 @@ class HostedPublicWorkspaceIT {
|
||||
.timeout(Duration.ofSeconds(10)).header("X-Qwen-Tenant-Id", "other-tenant")
|
||||
.header("X-G0-Fixture-Actor", "actor").build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(crossTenant.statusCode()).isEqualTo(403);
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'READER' WHERE tenant_id = ?", tenant);
|
||||
request("POST", "/v1/agents/sessions", denied, "read-only", "actor", 403);
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'OPERATOR' WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("UPDATE managed_workspace_registry SET config_ref = 'unsupported' WHERE tenant_id = ?", tenant);
|
||||
assertUnavailable(request("POST", "/v1/agents/sessions", denied, "unsupported", "actor", 409));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_agent_session WHERE tenant_id = ?",
|
||||
@@ -776,13 +777,13 @@ class HostedPublicWorkspaceIT {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation,"
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state) VALUES (?, ?, 1, ?, 'G0', ?, ?, 'ACTIVE')",
|
||||
tenant, workspace, storage, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, TRUE)", tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, 'OPERATOR')", tenant, workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
// The reader grant exists in both runs so the later-Turn block can also run under
|
||||
// approval-mode=default without colliding with the access table's primary key.
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, ?)", tenant, workspace, "reader".getBytes(StandardCharsets.UTF_8),
|
||||
!approvals);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, ?)", tenant, workspace, "reader".getBytes(StandardCharsets.UTF_8),
|
||||
!approvals ? "OPERATOR" : "READER");
|
||||
}
|
||||
|
||||
private void answerActions(String session, boolean web) throws Exception {
|
||||
|
||||
+2
-2
@@ -237,8 +237,8 @@ class HostedWorkspaceToolTurnIT {
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state) VALUES (?, ?, 1, ?,"
|
||||
+ " 'Workspace', ?, ?, 'ACTIVE')", tenant, workspaceId, "storage-" + index,
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, TRUE)", tenant, workspaceId, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, 'OPERATOR')", tenant, workspaceId, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
var created = store.insertWorkspaceSessionCommand(tenant, "actor", "create-" + index,
|
||||
"sha256:" + "a".repeat(64), "qwen-code", null, null, List.of(), null,
|
||||
new WorkspaceSelection(workspaceId, "child"));
|
||||
|
||||
+2
-2
@@ -373,8 +373,8 @@ class ManagedActionsTest {
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, ?, 'workspace', ?, ?, 'ACTIVE')",
|
||||
tenant, storage, WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, "owner".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "owner".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
jdbc.update("UPDATE managed_agent_session SET workspace_storage_id = ?, workspace_id = 'workspace',"
|
||||
+ " workspace_generation = 1, cwd_relative = '.', context_config_ref = ?, context_revision = 1,"
|
||||
+ " workspace_config_ref = ?, workspace_policy_ref = ? WHERE tenant_id = ? AND session_id = ?",
|
||||
|
||||
+4
-4
@@ -263,8 +263,8 @@ class ManagedAgentApiContractTest {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, display_name,"
|
||||
+ " workspace_generation, storage_id, config_ref, policy_ref, state)"
|
||||
+ " VALUES (?, 'workspace', 'Workspace', 1, 'storage', 'config', 'policy', 'ACTIVE')", tenant);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, actor.actorId().getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, actor.actorId().getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO qwen_runtime_storage_fence VALUES (?, ?, ?, 'storage', ?)",
|
||||
JdbcRuntimeBindingRepository.storageFenceKey(tenant), JdbcRuntimeBindingRepository.storageFenceKey("storage"),
|
||||
tenant, UUID.randomUUID().toString());
|
||||
@@ -1032,8 +1032,8 @@ class ManagedAgentApiContractTest {
|
||||
+ " 'ACTIVE')", workspaceTenant, workspaceId,
|
||||
workspaceId);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, TRUE)", workspaceTenant,
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, 'OPERATOR')", workspaceTenant,
|
||||
workspaceId, actor.actorId().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
jdbc.update("INSERT INTO managed_workspace_default"
|
||||
|
||||
+6
-6
@@ -511,8 +511,8 @@ class ManagedAgentMySqlIT {
|
||||
id, "config-a", "policy-a");
|
||||
jdbc.update("INSERT INTO managed_workspace_access"
|
||||
+ " (tenant_id, workspace_id, actor_id,"
|
||||
+ " can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, TRUE)",
|
||||
+ " role)"
|
||||
+ " VALUES (?, ?, ?, 'OPERATOR')",
|
||||
tenant, id, "actor-a".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
jdbc.update("INSERT INTO managed_workspace_default"
|
||||
@@ -593,13 +593,13 @@ class ManagedAgentMySqlIT {
|
||||
+ " 'ACTIVE')", tenant, workspace);
|
||||
jdbc.update("INSERT INTO managed_workspace_access"
|
||||
+ " (tenant_id, workspace_id, actor_id,"
|
||||
+ " can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, TRUE)",
|
||||
+ " role)"
|
||||
+ " VALUES (?, ?, ?, 'OPERATOR')",
|
||||
tenant, workspace, actor.getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access"
|
||||
+ " (tenant_id, workspace_id, actor_id,"
|
||||
+ " can_read, can_create)"
|
||||
+ " VALUES (?, 'Workspace-0', ?, TRUE, TRUE)",
|
||||
+ " role)"
|
||||
+ " VALUES (?, 'Workspace-0', ?, 'OPERATOR')",
|
||||
tenant, "other".getBytes(StandardCharsets.UTF_8));
|
||||
Admission created = transactions.execute(status ->
|
||||
store.insertWorkspaceSessionCommand(tenant, actor,
|
||||
|
||||
+2
-2
@@ -70,7 +70,7 @@ class ManagedArtifactApiIntegrationTest {
|
||||
+ " VALUES ('tenant-1', 'workspace-1', 1, 'storage-1', 'Test', 'config', 'policy', 'ACTIVE')");
|
||||
for (String actor : new String[] {"reader", "metadata-reader"}) {
|
||||
fixture.jdbc().update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id,"
|
||||
+ " can_read, can_create) VALUES ('tenant-1', 'workspace-1', ?, TRUE, FALSE)",
|
||||
+ " role) VALUES ('tenant-1', 'workspace-1', ?, 'READER')",
|
||||
actor.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
sessions = new ManagedAgentService(fixture.sessions(), new RequestDigests(),
|
||||
@@ -246,7 +246,7 @@ class ManagedArtifactApiIntegrationTest {
|
||||
mvc.perform(asActor(bytes(stdout), "tenant-1", "metadata-reader").header("Range", "malformed"))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.error.code").value("artifact_content_forbidden"))
|
||||
.andExpect(header().doesNotExist("Content-Range"));
|
||||
fixture.jdbc().update("UPDATE managed_workspace_access SET can_read = FALSE WHERE actor_id = ?",
|
||||
fixture.jdbc().update("DELETE FROM managed_workspace_access WHERE actor_id = ?",
|
||||
"reader".getBytes(StandardCharsets.UTF_8));
|
||||
mvc.perform(asReader(bytes(stdout))).andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
+11
-11
@@ -309,14 +309,14 @@ class ManagedCwdChangeOperationTest {
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertRefusal(error, HttpStatus.NOT_FOUND,
|
||||
"session_not_found"));
|
||||
fixture.grant(TENANT, WS, "colleague", true, true);
|
||||
fixture.grant(TENANT, WS, "colleague", "OPERATOR");
|
||||
assertThatThrownBy(() -> begin(fixture, sessionId, "key", "digest",
|
||||
"a", 1, "colleague", "digest-colleague"))
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertRefusal(error, HttpStatus.FORBIDDEN,
|
||||
"session_operation_forbidden"));
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET"
|
||||
+ " can_create = FALSE WHERE tenant_id = ? AND"
|
||||
+ " role = 'READER' WHERE tenant_id = ? AND"
|
||||
+ " workspace_id = ?", TENANT, WS);
|
||||
assertThatThrownBy(() -> begin(fixture, sessionId, "key", "digest",
|
||||
"a", 1))
|
||||
@@ -335,8 +335,8 @@ class ManagedCwdChangeOperationTest {
|
||||
OperationAdmission admitted = begin(fixture, sessionId, "key",
|
||||
"digest", "services/b", 1);
|
||||
assertThat(admitted.replayed()).isFalse();
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET"
|
||||
+ " can_read = FALSE WHERE tenant_id = ? AND"
|
||||
fixture.jdbc.update("DELETE FROM managed_workspace_access"
|
||||
+ " WHERE tenant_id = ? AND"
|
||||
+ " workspace_id = ? AND actor_id = ?", TENANT, WS,
|
||||
ACTOR.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
assertThatThrownBy(() -> begin(fixture, sessionId, "key", "digest",
|
||||
@@ -574,7 +574,7 @@ class ManagedCwdChangeOperationTest {
|
||||
OperationRecord revokedClaim = claim(fixture, revokedId, revokedOp,
|
||||
"owner");
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET"
|
||||
+ " can_create = FALSE WHERE tenant_id = ? AND"
|
||||
+ " role = 'READER' WHERE tenant_id = ? AND"
|
||||
+ " workspace_id = ?", TENANT, WS);
|
||||
assertThat(settle(fixture,
|
||||
revokedId, revokedOp, "owner",
|
||||
@@ -674,7 +674,7 @@ class ManagedCwdChangeOperationTest {
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertRefusal(error, HttpStatus.NOT_FOUND,
|
||||
"session_not_found"));
|
||||
fixture.grant(TENANT, WS, "colleague", true, true);
|
||||
fixture.grant(TENANT, WS, "colleague", "OPERATOR");
|
||||
assertThatThrownBy(() -> begin(fixture, archivedId, "key",
|
||||
"digest", "a", 1, "colleague", "digest-colleague"))
|
||||
.isInstanceOfSatisfying(ApiException.class,
|
||||
@@ -1459,7 +1459,7 @@ class ManagedCwdChangeOperationTest {
|
||||
+ " DUPLICATE KEY UPDATE workspace_id ="
|
||||
+ " workspace_id", tenant, workspaceId,
|
||||
STORAGE, workspaceId);
|
||||
grant(tenant, workspaceId, ACTOR, true, true);
|
||||
grant(tenant, workspaceId, ACTOR, "OPERATOR");
|
||||
return store.insertWorkspaceSessionCommand(tenant,
|
||||
ACTOR, "create-" + UUID.randomUUID(),
|
||||
"create-digest", "qwen-code", null, null,
|
||||
@@ -1470,13 +1470,13 @@ class ManagedCwdChangeOperationTest {
|
||||
}
|
||||
|
||||
void grant(String tenant, String workspaceId, String actor,
|
||||
boolean read, boolean create) {
|
||||
String role) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read,"
|
||||
+ " can_create) VALUES (?, ?, ?, ?, ?)"
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, ?)"
|
||||
+ " ON DUPLICATE KEY UPDATE actor_id = actor_id",
|
||||
tenant, workspaceId, actor.getBytes(java.nio.charset
|
||||
.StandardCharsets.UTF_8), read, create);
|
||||
.StandardCharsets.UTF_8), role);
|
||||
}
|
||||
|
||||
void insertAction(String sessionId, String actionId,
|
||||
|
||||
+2
-2
@@ -778,8 +778,8 @@ class ManagedSessionLifecycleTest {
|
||||
+ " VALUES (?, 'ws-a', 1, 'storage-a', 'ws-a',"
|
||||
+ " 'config', 'policy', 'ACTIVE')", tenant);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'ws-a', ?, TRUE, TRUE)", tenant,
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'ws-a', ?, 'OPERATOR')", tenant,
|
||||
"actor-a".getBytes(StandardCharsets.UTF_8));
|
||||
String sessionId = objectMapper.readTree(mvc.perform(post(
|
||||
"/v1/agents/sessions").header(TENANT, tenant)
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
|
||||
import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
class ManagedSessionOwnerTest {
|
||||
@Test
|
||||
void writesTheOwnerAsTheCreatorOnEveryCreationPath() {
|
||||
JdbcDataSource source = new JdbcDataSource();
|
||||
source.setURL("jdbc:h2:mem:session-owner-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
|
||||
Flyway.configure().dataSource(source).load().migrate();
|
||||
JdbcTemplate jdbc = new JdbcTemplate(source);
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id,"
|
||||
+ " workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES"
|
||||
+ " ('tenant', 'workspace', 1, 'storage', 'Workspace', ?, ?,"
|
||||
+ " 'ACTIVE')", WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, role) VALUES ('tenant',"
|
||||
+ " 'workspace', ?, 'OPERATOR')",
|
||||
"actor".getBytes(StandardCharsets.UTF_8));
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
var store = new ManagedAgentStore(jdbc, new ObjectMapper(),
|
||||
Clock.systemUTC(), ignored -> { },
|
||||
new ManagedWorkspaceRegistry(jdbc), properties);
|
||||
var transactions = new TransactionTemplate(
|
||||
new DataSourceTransactionManager(source));
|
||||
|
||||
String bound = transactions.execute(ignored -> store
|
||||
.insertWorkspaceSessionCommand("tenant", "actor", "bound",
|
||||
"digest-bound", "qwen-code", null, null, List.of(),
|
||||
null, new WorkspaceSelection("workspace", "."))
|
||||
.sessionId());
|
||||
assertThat(jdbc.queryForObject("SELECT owner_actor_key FROM"
|
||||
+ " managed_agent_session WHERE session_id = ?",
|
||||
byte[].class, bound))
|
||||
.isEqualTo("actor".getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(jdbc.queryForObject("SELECT owner_actor_key FROM"
|
||||
+ " managed_agent_session WHERE session_id = ?",
|
||||
byte[].class, bound))
|
||||
.isEqualTo(jdbc.queryForObject("SELECT creator_actor_key FROM"
|
||||
+ " managed_agent_session WHERE session_id = ?",
|
||||
byte[].class, bound));
|
||||
|
||||
store.insertSessionCommand("tenant", "actor", "CREATE_SESSION",
|
||||
"legacy", "digest-legacy", "qwen-code", null, null, List.of(),
|
||||
null);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM"
|
||||
+ " managed_agent_session WHERE owner_actor_key"
|
||||
+ " = creator_actor_key AND creator_actor_key IS NOT NULL",
|
||||
Integer.class)).isEqualTo(2);
|
||||
|
||||
store.insertSessionCommand("tenant", "CREATE_SESSION", "anonymous",
|
||||
"digest-anonymous", "qwen-code", null, null, List.of(),
|
||||
null);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM"
|
||||
+ " managed_agent_session WHERE owner_actor_key IS NULL"
|
||||
+ " AND creator_actor_key IS NULL", Integer.class))
|
||||
.isEqualTo(1);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -339,8 +339,8 @@ class ManagedTurnQueryTest {
|
||||
+ " VALUES (?, 'ws-a', 1, 'storage-a', 'ws-a',"
|
||||
+ " 'config', 'policy', 'ACTIVE')", tenant);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'ws-a', ?, TRUE, TRUE)", tenant,
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'ws-a', ?, 'OPERATOR')", tenant,
|
||||
"actor-a".getBytes(StandardCharsets.UTF_8));
|
||||
String sessionId = objectMapper.readTree(mvc.perform(post(
|
||||
"/v1/agents/sessions").header(TENANT, tenant)
|
||||
|
||||
+11
-9
@@ -271,7 +271,7 @@ class ManagedWorkspaceAdmissionTest {
|
||||
.principal(actor(tenant, "actor-a")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.default_workspace").value((Object) null));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE"
|
||||
jdbc.update("DELETE FROM managed_workspace_access"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ?",
|
||||
tenant, "b-visible");
|
||||
mvc.perform(get("/v1/agents/workspaces/b-visible")
|
||||
@@ -441,7 +441,7 @@ class ManagedWorkspaceAdmissionTest {
|
||||
+ " managed_agent_command WHERE tenant_id = ?"
|
||||
+ " AND session_id = ?", Integer.class, tenant,
|
||||
sessionId)).isZero();
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE"
|
||||
jdbc.update("DELETE FROM managed_workspace_access"
|
||||
+ " WHERE tenant_id = ? AND workspace_id = ?"
|
||||
+ " AND actor_id = ?", tenant, "ws-a",
|
||||
"actor-a".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
@@ -1159,11 +1159,13 @@ class ManagedWorkspaceAdmissionTest {
|
||||
assertCreateError(tenant, selection, "workspace_not_found");
|
||||
grant(tenant, "ws-a", "actor-a", false);
|
||||
assertCreateError(tenant, selection, "workspace_forbidden");
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE"
|
||||
jdbc.update("DELETE FROM managed_workspace_access"
|
||||
+ " WHERE tenant_id = ?", tenant);
|
||||
assertCreateError(tenant, selection, "workspace_not_found");
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = TRUE, can_create = TRUE"
|
||||
+ " WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, role) VALUES (?, 'ws-a', ?,"
|
||||
+ " 'OPERATOR')", tenant,
|
||||
"actor-a".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
jdbc.update("UPDATE managed_workspace_registry SET state = 'REMOVED'"
|
||||
+ " WHERE tenant_id = ?", tenant);
|
||||
assertCreateError(tenant, selection, "workspace_unavailable");
|
||||
@@ -1184,7 +1186,7 @@ class ManagedWorkspaceAdmissionTest {
|
||||
assertThat(optedOut.getWebShellSession(tenant, "actor-a", sessionId)
|
||||
.capabilities().workspaceTurns()).isFalse();
|
||||
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE"
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'READER'"
|
||||
+ " WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING'"
|
||||
+ " WHERE tenant_id = ?", tenant);
|
||||
@@ -1489,11 +1491,11 @@ class ManagedWorkspaceAdmissionTest {
|
||||
private void grant(String tenant, String workspaceId, String actorId,
|
||||
boolean canCreate) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, ?)",
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, ?)",
|
||||
tenant, workspaceId,
|
||||
actorId.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||
canCreate);
|
||||
canCreate ? "OPERATOR" : "READER");
|
||||
}
|
||||
|
||||
private static AuthenticatedTenantActor actor(String tenant,
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
class ManagedWorkspaceRolesMigrationTest {
|
||||
private static final byte[] OPERATOR = "operator"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] READER = "reader"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CREATOR = "creator"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
void backfillsRolesAndSessionOwnersAndRejectsNoRole() {
|
||||
JdbcDataSource source = new JdbcDataSource();
|
||||
source.setURL("jdbc:h2:mem:workspace-roles-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
|
||||
Flyway.configure().dataSource(source).target("47").load().migrate();
|
||||
JdbcTemplate jdbc = new JdbcTemplate(source);
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id,"
|
||||
+ " workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES"
|
||||
+ " ('tenant', 'workspace', 1, 'storage', 'Workspace',"
|
||||
+ " 'config', 'policy', 'ACTIVE')");
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create) VALUES"
|
||||
+ " ('tenant', 'workspace', ?, TRUE, TRUE)", OPERATOR);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create) VALUES"
|
||||
+ " ('tenant', 'workspace', ?, TRUE, FALSE)", READER);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create) VALUES"
|
||||
+ " ('tenant', 'workspace', ?, FALSE, TRUE)",
|
||||
"write-no-read".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create) VALUES"
|
||||
+ " ('tenant', 'workspace', ?, FALSE, FALSE)",
|
||||
"no-access".getBytes(StandardCharsets.UTF_8));
|
||||
insertSession(jdbc, "owned", CREATOR);
|
||||
insertSession(jdbc, "anonymous", null);
|
||||
|
||||
Flyway.configure().dataSource(source).load().migrate();
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT role FROM"
|
||||
+ " managed_workspace_access WHERE actor_id = ?",
|
||||
String.class, OPERATOR)).isEqualTo("OPERATOR");
|
||||
assertThat(jdbc.queryForObject("SELECT role FROM"
|
||||
+ " managed_workspace_access WHERE actor_id = ?",
|
||||
String.class, READER)).isEqualTo("READER");
|
||||
// Rows without can_read granted nothing under the booleans and are
|
||||
// dropped instead of gaining a role through the backfill.
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM"
|
||||
+ " managed_workspace_access WHERE actor_id IN (?, ?)",
|
||||
Integer.class, "write-no-read".getBytes(StandardCharsets.UTF_8),
|
||||
"no-access".getBytes(StandardCharsets.UTF_8))).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT owner_actor_key FROM"
|
||||
+ " managed_agent_session WHERE session_id = 'owned'",
|
||||
byte[].class)).isEqualTo(CREATOR);
|
||||
assertThat(jdbc.queryForObject("SELECT owner_actor_key FROM"
|
||||
+ " managed_agent_session WHERE session_id = 'anonymous'",
|
||||
byte[].class)).isNull();
|
||||
// Fresh actor keys per negative case: reusing a seeded key would
|
||||
// let a primary-key clash (also a DataIntegrityViolationException)
|
||||
// mask the constraint actually being probed.
|
||||
assertThatThrownBy(() -> jdbc.update("INSERT INTO"
|
||||
+ " managed_workspace_access (tenant_id, workspace_id,"
|
||||
+ " actor_id, role) VALUES ('tenant', 'workspace', ?,"
|
||||
+ " 'NONE')", "none-actor".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
assertThatThrownBy(() -> jdbc.update("INSERT INTO"
|
||||
+ " managed_workspace_access (tenant_id, workspace_id,"
|
||||
+ " actor_id, role) VALUES ('tenant', 'workspace', ?,"
|
||||
+ " 'SPECTATOR')",
|
||||
"spectator-actor".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
// role carries no default: an INSERT omitting it must fail loudly,
|
||||
// as omitting a boolean did under the old NOT NULL columns.
|
||||
assertThatThrownBy(() -> jdbc.update("INSERT INTO"
|
||||
+ " managed_workspace_access (tenant_id, workspace_id,"
|
||||
+ " actor_id) VALUES ('tenant', 'workspace', ?)",
|
||||
"default-actor".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
// PAD SPACE comparisons accept a padded value into an IN-list
|
||||
// CHECK; the length-guarded CHECK stores only byte-exact enum
|
||||
// names.
|
||||
assertThatThrownBy(() -> jdbc.update("INSERT INTO"
|
||||
+ " managed_workspace_access (tenant_id, workspace_id,"
|
||||
+ " actor_id, role) VALUES ('tenant', 'workspace', ?,"
|
||||
+ " 'READER ')",
|
||||
"padded-actor".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
|
||||
// A creation written after V53 keeps owner = creator on the store
|
||||
// path too; the two columns now only diverge through the handover
|
||||
// command that a later slice adds.
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, role) VALUES ('tenant',"
|
||||
+ " 'workspace', ?, 'OPERATOR')", CREATOR);
|
||||
var properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
var store = new ManagedAgentStore(jdbc, new ObjectMapper(),
|
||||
Clock.systemUTC(), ignored -> { },
|
||||
new ManagedWorkspaceRegistry(jdbc), properties);
|
||||
var transactions = new TransactionTemplate(
|
||||
new DataSourceTransactionManager(source));
|
||||
String sessionId = transactions.execute(ignored -> store
|
||||
.insertWorkspaceSessionCommand("tenant", "creator", "create",
|
||||
"digest", "qwen-code", null, null, List.of(), null,
|
||||
new WorkspaceSelection("workspace", "."))
|
||||
.sessionId());
|
||||
assertThat(jdbc.queryForObject("SELECT owner_actor_key FROM"
|
||||
+ " managed_agent_session WHERE session_id = ?",
|
||||
byte[].class, sessionId)).isEqualTo(CREATOR);
|
||||
}
|
||||
|
||||
private static void insertSession(JdbcTemplate jdbc, String id,
|
||||
byte[] creatorKey) {
|
||||
jdbc.update("INSERT INTO managed_agent_session"
|
||||
+ " (tenant_id, session_id, agent_id, status, created_at,"
|
||||
+ " updated_at) VALUES ('tenant', ?, 'qwen-code', 'ACTIVE',"
|
||||
+ " 1, 1)", id);
|
||||
if (creatorKey != null) {
|
||||
jdbc.update("UPDATE managed_agent_session SET creator_actor_key"
|
||||
+ " = ? WHERE session_id = ?", creatorKey, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -169,8 +169,8 @@ class SurfaceAdmissionAcceptanceTest {
|
||||
+ " 'ACTIVE')", ARTIFACT_TENANT, ARTIFACT_WORKSPACE);
|
||||
for (String actor : List.of(READER, OWNER)) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, ?, ?, TRUE, FALSE)", ARTIFACT_TENANT,
|
||||
+ " workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, 'READER')", ARTIFACT_TENANT,
|
||||
ARTIFACT_WORKSPACE, actor.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
var artifacts = fixture.results()
|
||||
@@ -1032,9 +1032,10 @@ class SurfaceAdmissionAcceptanceTest {
|
||||
|
||||
private void grant(String tenant, String actor, boolean canCreate) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create) VALUES"
|
||||
+ " (?, 'ws', ?, TRUE, ?)", tenant,
|
||||
actor.getBytes(StandardCharsets.UTF_8), canCreate);
|
||||
+ " workspace_id, actor_id, role) VALUES"
|
||||
+ " (?, 'ws', ?, ?)", tenant,
|
||||
actor.getBytes(StandardCharsets.UTF_8),
|
||||
canCreate ? "OPERATOR" : "READER");
|
||||
}
|
||||
|
||||
private String insertAction(String tenant, String session)
|
||||
|
||||
+2
-2
@@ -128,8 +128,8 @@ class ToolPublicationLifecycleMySqlIT {
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state)"
|
||||
+ " VALUES ('tenant-1', 'neighbor', 1, 'storage', 'Neighbor', ?, ?, 'ACTIVE')",
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES ('tenant-1', 'neighbor', ?, TRUE, TRUE)", "owner".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES ('tenant-1', 'neighbor', ?, 'OPERATOR')", "owner".getBytes(StandardCharsets.UTF_8));
|
||||
String session = transaction.execute(ignored -> agents.insertWorkspaceSessionCommand("tenant-1", "owner",
|
||||
"create", "digest", "qwen-code", null, null, List.of(), null,
|
||||
new WorkspaceSelection("neighbor", ".")).sessionId());
|
||||
|
||||
+2
-2
@@ -274,8 +274,8 @@ class WorkspaceSessionCloseMySqlIT {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, 'storage', 'Workspace', ?, ?, 'ACTIVE')",
|
||||
tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, OWNER.getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, OWNER.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return transaction(() -> store.insertWorkspaceSessionCommand(tenant, OWNER, UUID.randomUUID().toString(),
|
||||
"create", "qwen-code", null, null, List.of(), null, new WorkspaceSelection("workspace", "."))).sessionId();
|
||||
|
||||
+2
-2
@@ -189,8 +189,8 @@ class WorkspaceSessionCloseTest {
|
||||
}
|
||||
|
||||
private void grant(String tenant, String actor) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'ws', ?, TRUE, TRUE)", tenant, actor.getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'ws', ?, 'OPERATOR')", tenant, actor.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private AuthenticatedTenantActor actor(String tenant, String id) {
|
||||
|
||||
+5
-3
@@ -179,7 +179,7 @@ class WorkspaceSessionRetentionMySqlIT {
|
||||
publication(tenant, session);
|
||||
jdbc.update("UPDATE qwen_managed_session_journal_head SET recovery_status = 'BLOCKED'");
|
||||
var claimed = deleteClaim(store, tenant, session);
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE, can_create = FALSE");
|
||||
jdbc.update("DELETE FROM managed_workspace_access");
|
||||
assertThat(transaction(() -> store.completeOperation(tenant, session, claimed.operationId(), "delete-worker", claimed.claimGeneration(), false))).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT recovery_protected FROM qwen_output_session_retirement", Boolean.class)).isTrue();
|
||||
var retention = new ToolPublicationRetentionStore(jdbc, new DataSourceTransactionManager(source));
|
||||
@@ -323,6 +323,8 @@ class WorkspaceSessionRetentionMySqlIT {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, 'storage', 'Workspace', ?, ?, 'ACTIVE')",
|
||||
tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
// Seeded at V31, where the grant columns are still the booleans; the
|
||||
// V52 backfill maps this (TRUE, TRUE) row to OPERATOR on upgrade.
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, OWNER.getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_agent_session (tenant_id, session_id, agent_id, status, created_at, updated_at,"
|
||||
@@ -483,8 +485,8 @@ class WorkspaceSessionRetentionMySqlIT {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, 'storage', 'Workspace', ?, ?, 'ACTIVE')",
|
||||
tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, OWNER.getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, OWNER.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return transaction(() -> store.insertWorkspaceSessionCommand(tenant, OWNER, UUID.randomUUID().toString(),
|
||||
"create", "qwen-code", null, null, List.of(), null, new WorkspaceSelection("workspace", "."))).sessionId();
|
||||
|
||||
+1
-1
@@ -344,7 +344,7 @@ class WorkspaceSessionRetentionTest {
|
||||
Integer.class, tenant, session, type);
|
||||
}
|
||||
private void grant(String tenant, String actor) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create) VALUES (?, 'ws', ?, TRUE, TRUE)",
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role) VALUES (?, 'ws', ?, 'OPERATOR')",
|
||||
tenant, actor.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
private AuthenticatedTenantActor actor(String tenant, String id) {
|
||||
|
||||
+6
-6
@@ -75,8 +75,8 @@ class QwenHostedHarnessColdCancelRegressionTest {
|
||||
+ " workspace_generation, storage_id, display_name, config_ref, policy_ref, state)"
|
||||
+ " VALUES (?, 'ws-a', 1, 'storage-a', 'Workspace', ?, ?, 'ACTIVE')",
|
||||
tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'ws-a', ?, TRUE, TRUE)",
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'ws-a', ?, 'OPERATOR')",
|
||||
tenant, "actor-a".getBytes(StandardCharsets.UTF_8));
|
||||
ManagedAgentProperties properties = new ManagedAgentProperties();
|
||||
properties.getHarness().setWorkspaceFilesEnabled(true);
|
||||
@@ -129,7 +129,7 @@ class QwenHostedHarnessColdCancelRegressionTest {
|
||||
assertThatThrownBy(() -> connector.recoverManagedRuntime(tenant, session, true))
|
||||
.isInstanceOfSatisfying(RuntimeBrokerException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo("workspace_unavailable"));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'READER' WHERE tenant_id = ?", tenant);
|
||||
assertThat(store.insertCancelCommand(tenant, "CANCEL", "cancel", "digest", session, turn)
|
||||
.commandEffect()).isTrue();
|
||||
|
||||
@@ -147,9 +147,9 @@ class QwenHostedHarnessColdCancelRegressionTest {
|
||||
clearInvocations(client);
|
||||
doNothing().when(client).cancelTurn(attached);
|
||||
coordinator.cancel(tenant, session, turn);
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'OPERATOR' WHERE tenant_id = ?", tenant);
|
||||
assertThat(connector.recoverManagedRuntime(tenant, session, false).runtimeRecovery()).isNull();
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE WHERE tenant_id = ?", tenant);
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'READER' WHERE tenant_id = ?", tenant);
|
||||
|
||||
assertThat(store.findTurn(tenant, session, turn).orElseThrow().status()).isEqualTo("CANCELLING");
|
||||
verify(client).cancelTurn(attached);
|
||||
@@ -159,7 +159,7 @@ class QwenHostedHarnessColdCancelRegressionTest {
|
||||
verify(client, never()).submitTurn(any());
|
||||
|
||||
for (String change : List.of(
|
||||
"UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?",
|
||||
"DELETE FROM managed_workspace_access WHERE tenant_id = ?",
|
||||
"UPDATE managed_workspace_registry SET state = 'DRAINING' WHERE tenant_id = ?",
|
||||
"UPDATE managed_workspace_registry SET workspace_generation = workspace_generation + 1 WHERE tenant_id = ?",
|
||||
"UPDATE managed_workspace_registry SET storage_id = 'replacement-storage' WHERE tenant_id = ?")) {
|
||||
|
||||
+11
-11
@@ -458,8 +458,8 @@ class Issue13181QueryBudgetTest {
|
||||
+ " 'ACTIVE')", tenant, WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
fixture.jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create) VALUES"
|
||||
+ " (?, 'workspace', ?, TRUE, TRUE)", tenant,
|
||||
+ " workspace_id, actor_id, role) VALUES"
|
||||
+ " (?, 'workspace', ?, 'OPERATOR')", tenant,
|
||||
"actor".getBytes(StandardCharsets.UTF_8));
|
||||
String sessionId = fixture.tx.execute(status -> fixture.store
|
||||
.insertWorkspaceSessionCommand(tenant, "actor",
|
||||
@@ -677,8 +677,8 @@ class Issue13181QueryBudgetTest {
|
||||
+ " 'ACTIVE')", bound, WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
fixture.jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, can_read, can_create) VALUES"
|
||||
+ " (?, 'workspace', ?, TRUE, TRUE)", bound,
|
||||
+ " workspace_id, actor_id, role) VALUES"
|
||||
+ " (?, 'workspace', ?, 'OPERATOR')", bound,
|
||||
"actor".getBytes(StandardCharsets.UTF_8));
|
||||
List<String> boundIds = new ArrayList<>();
|
||||
for (int index = 0; index < 20; index++) {
|
||||
@@ -762,12 +762,12 @@ class Issue13181QueryBudgetTest {
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
for (String actor : new String[] {"actor", "other"}) {
|
||||
fixture.jdbc.update("INSERT INTO managed_workspace_access"
|
||||
+ " (tenant_id, workspace_id, actor_id, can_read,"
|
||||
+ " can_create) VALUES (?, 'workspace', ?, TRUE, TRUE)",
|
||||
+ " (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')",
|
||||
tenant, actor.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
// A second workspace where the actor reads but cannot create: its
|
||||
// session exercises the grant's can_create term, and the page's two
|
||||
// session exercises the grant's role term, and the page's two
|
||||
// workspaces make the grant batch's single-query shape
|
||||
// discriminable.
|
||||
fixture.jdbc.update("INSERT INTO managed_workspace_registry"
|
||||
@@ -777,8 +777,8 @@ class Issue13181QueryBudgetTest {
|
||||
+ " ?, 'ACTIVE')", tenant, WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
fixture.jdbc.update("INSERT INTO managed_workspace_access"
|
||||
+ " (tenant_id, workspace_id, actor_id, can_read,"
|
||||
+ " can_create) VALUES (?, 'workspace2', ?, TRUE, TRUE)",
|
||||
+ " (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace2', ?, 'OPERATOR')",
|
||||
tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (int index = 0; index < 7; index++) {
|
||||
@@ -794,13 +794,13 @@ class Issue13181QueryBudgetTest {
|
||||
.sessionId());
|
||||
}
|
||||
// One creator-owned session is closed: the shape gate fences it even
|
||||
// for its creator. The workspace2 grant then drops can_create: its
|
||||
// for its creator. The workspace2 grant then drops to READER: its
|
||||
// session exercises the grant term.
|
||||
fixture.jdbc.update("UPDATE managed_agent_session SET status ="
|
||||
+ " 'CLOSED' WHERE tenant_id = ? AND session_id = ?", tenant,
|
||||
ids.get(3));
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET"
|
||||
+ " can_create = FALSE WHERE tenant_id = ? AND workspace_id"
|
||||
+ " role = 'READER' WHERE tenant_id = ? AND workspace_id"
|
||||
+ " = 'workspace2'", tenant);
|
||||
fixture.ledger.reset();
|
||||
var page = fixture.service.listWebShellSessions(tenant, "actor",
|
||||
|
||||
+2
-2
@@ -41,8 +41,8 @@ class SessionLifecycleCoordinatorTest {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES ('tenant', 'workspace', 1, 'storage', 'Workspace', ?, ?, 'ACTIVE')",
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES ('tenant', 'workspace', ?, TRUE, TRUE)", "owner".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES ('tenant', 'workspace', ?, 'OPERATOR')", "owner".getBytes(StandardCharsets.UTF_8));
|
||||
var transactions = new TransactionTemplate(new DataSourceTransactionManager(source));
|
||||
String session = transactions.execute(ignored -> store.insertWorkspaceSessionCommand("tenant", "owner", "create", "digest", "qwen-code",
|
||||
null, null, List.of(), null, new WorkspaceSelection("workspace", ".")).sessionId());
|
||||
|
||||
+2
-2
@@ -280,8 +280,8 @@ public final class WorkspaceRecoveryContract {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation,"
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, 'storage',"
|
||||
+ " 'Workspace', ?, ?, 'ACTIVE')", tenant, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
var transaction = new org.springframework.transaction.support.TransactionTemplate(
|
||||
new org.springframework.jdbc.datasource.DataSourceTransactionManager(source));
|
||||
var created = transaction.execute(status -> store.insertWorkspaceSessionCommand(
|
||||
|
||||
+2
-3
@@ -79,9 +79,8 @@ class WorkspaceRuntimeResolutionPivotTest {
|
||||
WorkspaceExecutionProfile.CONFIG_REF,
|
||||
WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access"
|
||||
+ " (tenant_id, workspace_id, actor_id,"
|
||||
+ " can_read, can_create) VALUES (?, ?, ?,"
|
||||
+ " TRUE, TRUE)", TENANT, WS,
|
||||
+ " (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, 'OPERATOR')", TENANT, WS,
|
||||
ACTOR.getBytes(java.nio.charset
|
||||
.StandardCharsets.UTF_8));
|
||||
return store.insertWorkspaceSessionCommand(TENANT, ACTOR,
|
||||
|
||||
+21
-18
@@ -198,7 +198,7 @@ class WorkspaceRuntimeTest {
|
||||
RuntimeProvisioner local = mock(RuntimeProvisioner.class);
|
||||
when(local.kind()).thenReturn("local-process");
|
||||
when(local.supportsStartupRecovery(any())).thenReturn(true);
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?",
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?",
|
||||
session.tenantId());
|
||||
jdbc.update("UPDATE managed_agent_session SET status = 'DELETED', deleted_at = 2"
|
||||
+ " WHERE session_id = ?", session.sessionId());
|
||||
@@ -222,8 +222,9 @@ class WorkspaceRuntimeTest {
|
||||
.isEqualTo(ToolExecutionRecord.State.SETTLED);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_execution_lease"
|
||||
+ " WHERE binding_id = ?", Long.class, runtime.getBindingId())).isZero();
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = TRUE WHERE tenant_id = ?",
|
||||
session.tenantId());
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')",
|
||||
session.tenantId(), "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("UPDATE managed_agent_session SET status = 'ACTIVE', deleted_at = NULL"
|
||||
+ " WHERE session_id = ?", session.sessionId());
|
||||
RuntimeSessionRecord next = holder(session, "next");
|
||||
@@ -255,11 +256,13 @@ class WorkspaceRuntimeTest {
|
||||
void refusesRevokedGrantsDeletedSessionsAndUnknownFrozenProfiles() throws Exception {
|
||||
SessionRecord session = createSession("storage", ".");
|
||||
var resolver = resolver(session, temp.toRealPath());
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'READER' WHERE tenant_id = ?", session.tenantId());
|
||||
assertUnavailable(() -> resolver.resolve(session.sessionId()));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE, can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
assertUnavailable(() -> resolver.resolve(session.sessionId()));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = TRUE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')",
|
||||
session.tenantId(), "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("UPDATE managed_agent_session SET status = 'DELETED', deleted_at = 2 WHERE session_id = ?", session.sessionId());
|
||||
assertUnavailable(() -> resolver.resolve(session.sessionId()));
|
||||
jdbc.update("UPDATE managed_agent_session SET status = 'ACTIVE', deleted_at = NULL,"
|
||||
@@ -291,7 +294,7 @@ class WorkspaceRuntimeTest {
|
||||
assertUnavailable(() -> checked.authorize(session));
|
||||
checked.authorizePassiveAttachment(session);
|
||||
verify(guard).verify(session.workspace());
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
assertUnavailable(() -> checked.authorizePassiveAttachment(session));
|
||||
}
|
||||
|
||||
@@ -409,7 +412,7 @@ class WorkspaceRuntimeTest {
|
||||
when(fixture.http().installContext(any(), any(), any(), any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(Map.of()));
|
||||
when(fixture.http().activateWorkspace(any(), any(), any(), eq(true))).thenAnswer(ignored -> {
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
return CompletableFuture.completedFuture(null);
|
||||
});
|
||||
assertThatThrownBy(() -> fixture.transport().acquire(fixture.lease(), fixture.record().getSession())
|
||||
@@ -531,7 +534,7 @@ class WorkspaceRuntimeTest {
|
||||
.containsEntry("tenantId", session.tenantId()).containsEntry("bindingGeneration", "1")
|
||||
.containsEntry("executionCallId", "execution");
|
||||
verify(fixture.http(), never()).execute(any(), any(), any());
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
assertUnavailable(() -> fixture.transport().installPublisher(fixture.lease(), runtimeSession, publisher));
|
||||
when(fixture.http().statusV3(any(), any(), any(), eq(0L))).thenReturn(CompletableFuture.completedFuture(response));
|
||||
when(fixture.http().cancelV3(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(response));
|
||||
@@ -548,7 +551,7 @@ class WorkspaceRuntimeTest {
|
||||
var fixture = transport(session);
|
||||
var runtimeSession = fixture.record().getSession();
|
||||
authority.claim(session.workspace(), fixture.record());
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
assertThat(fixture.transport().execute(fixture.lease(), runtimeSession, Map.of()).toCompletableFuture().join())
|
||||
.containsEntry("executionStatus", "not_started")
|
||||
.containsEntry("error", Map.of("type", "workspace_unavailable",
|
||||
@@ -579,7 +582,7 @@ class WorkspaceRuntimeTest {
|
||||
when(fixture.http().control(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(Map.of("state", "running")));
|
||||
assertThat(fixture.transport().control(fixture.lease(), runtimeSession, configure).toCompletableFuture().join())
|
||||
.isEqualTo(Map.of("state", "running"));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
for (String kind : List.of("mcp-configure", "mcp-discover")) {
|
||||
assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, mcpControl(session, kind)));
|
||||
}
|
||||
@@ -605,7 +608,7 @@ class WorkspaceRuntimeTest {
|
||||
checked, fixture.bindings(), new JdbcRuntimeSessionRepository(dataSource));
|
||||
assertUnavailable(() -> transport.control(fixture.lease(), runtimeSession, mcpControl(session, "mcp-configure")));
|
||||
verify(fixture.http(), never()).control(any(), any(), any());
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
String storageKey = digest(session.tenantId() + "\0" + session.workspace().getStorageId())
|
||||
.substring("sha256:".length());
|
||||
var originalHolder = jdbc.queryForMap("SELECT * FROM managed_workspace_execution_lease WHERE storage_key = ?", storageKey);
|
||||
@@ -649,7 +652,7 @@ class WorkspaceRuntimeTest {
|
||||
when(fixture.http().control(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(Map.of("state", "running")));
|
||||
assertThat(fixture.transport().control(fixture.lease(), runtimeSession, configure).toCompletableFuture().join())
|
||||
.isEqualTo(Map.of("state", "running"));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
for (String kind : List.of("hook-execute", "hook-catalog")) {
|
||||
assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, hookControl(session, kind)));
|
||||
}
|
||||
@@ -700,7 +703,7 @@ class WorkspaceRuntimeTest {
|
||||
verify(fixture.http()).control(fixture.lease(), runtimeSession, hook);
|
||||
}
|
||||
assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, mcpControl(session, "mcp-configure")));
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, hookControl(session, "hook-execute")));
|
||||
fixture.transport().control(fixture.lease(), runtimeSession, hookControl(session, "hook-status")).toCompletableFuture().join();
|
||||
authority.assertHeld(session.workspace(), fixture.record());
|
||||
@@ -724,7 +727,7 @@ class WorkspaceRuntimeTest {
|
||||
var fixture = transport(session);
|
||||
var runtimeSession = fixture.record().getSession();
|
||||
authority.claim(session.workspace(), fixture.record());
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
Map<String, Object> original = Map.of("callId", "original");
|
||||
when(fixture.http().statusV3(any(), any(), any(), eq(0L)))
|
||||
.thenReturn(CompletableFuture.completedFuture(Map.of("state", "executing")));
|
||||
@@ -758,7 +761,7 @@ class WorkspaceRuntimeTest {
|
||||
when(fixture.http().control(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(Map.of()));
|
||||
fixture.transport().control(fixture.lease(), runtimeSession, Map.of("kind", "manifest"))
|
||||
.toCompletableFuture().join();
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_read = FALSE WHERE tenant_id = ?", session.tenantId());
|
||||
jdbc.update("DELETE FROM managed_workspace_access WHERE tenant_id = ?", session.tenantId());
|
||||
assertUnavailable(() -> fixture.transport().control(fixture.lease(), runtimeSession, Map.of("kind", "prepare")));
|
||||
verify(fixture.http(), never()).control(any(), any(), eq(Map.of("kind", "prepare")));
|
||||
fixture.transport().control(fixture.lease(), runtimeSession, Map.of("kind", "history"))
|
||||
@@ -885,8 +888,8 @@ class WorkspaceRuntimeTest {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation,"
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state) VALUES (?, 'workspace', 1, ?,"
|
||||
+ " 'Workspace', ?, ?, 'ACTIVE')", tenant, storage, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
var created = sessions.insertWorkspaceSessionCommand(tenant, "actor", "create", "sha256:" + "a".repeat(64),
|
||||
"qwen-code", null, null, List.of(), null, new WorkspaceSelection("workspace", cwd));
|
||||
return sessions.findSessionById(created.sessionId()).orElseThrow();
|
||||
|
||||
+2
-2
@@ -90,8 +90,8 @@ class HostedWorkspaceConcurrencyIT {
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state)"
|
||||
+ " VALUES (?, 'workspace', 1, 'storage', 'Race', ?, ?, 'ACTIVE')", tenant,
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
var transaction = new TransactionTemplate(new DataSourceTransactionManager(source));
|
||||
String firstSession = transaction.execute(status -> store.insertWorkspaceSessionCommand(tenant, "actor",
|
||||
"first", "sha256:" + "a".repeat(64), "qwen-code", null, null, List.of(), null,
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
/**
|
||||
* Real-engine counterpart of the V53 role-constraint shape: utf8mb4
|
||||
* comparisons ignore trailing spaces (PAD SPACE), so an IN-list CHECK
|
||||
* would accept and preserve 'READER ' — a value the enum parser rejects
|
||||
* at read time. The CHECK pairs each name with its exact length; only
|
||||
* byte-exact enum names are storable. The assertion talks
|
||||
* DataAccessException plus the constraint name rather than
|
||||
* DataIntegrityViolationException, because MySQL reports a CHECK
|
||||
* violation as 3819/HY000, which Spring 6.2 cannot map, while MariaDB's
|
||||
* 4025/23000 maps to DataIntegrityViolationException.
|
||||
*/
|
||||
class ManagedWorkspaceRolesMySqlIT {
|
||||
private JdbcTemplate admin;
|
||||
private JdbcTemplate jdbc;
|
||||
private DriverManagerDataSource data;
|
||||
private String schema;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
String url = System.getProperty("mysql.url");
|
||||
if (url == null || !url.matches("jdbc:mysql://[^/]+/[^?]+(?:\\?.*)?")) {
|
||||
throw new IllegalArgumentException("A MySQL test database URL is required");
|
||||
}
|
||||
String user = System.getProperty("mysql.user");
|
||||
String password = System.getProperty("mysql.password", "");
|
||||
admin = new JdbcTemplate(new DriverManagerDataSource(url, user, password));
|
||||
schema = "workspace_roles_" + UUID.randomUUID().toString().replace("-", "");
|
||||
admin.execute("CREATE DATABASE " + schema);
|
||||
data = new DriverManagerDataSource(url.replaceFirst("/[^/?]+(?=\\?|$)", "/" + schema), user, password);
|
||||
jdbc = new JdbcTemplate(data);
|
||||
Flyway.configure().dataSource(data).locations("classpath:db/migration").load().migrate();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
if (admin != null && schema != null) {
|
||||
admin.execute("DROP DATABASE IF EXISTS " + schema);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleCheckStoresOnlyByteExactEnumNames() {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id,"
|
||||
+ " workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES"
|
||||
+ " ('tenant', 'workspace', 1, 'storage', 'Workspace',"
|
||||
+ " 'config', 'policy', 'ACTIVE')");
|
||||
for (String canonical : List.of("READER", "OPERATOR", "OWNER")) {
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id,"
|
||||
+ " workspace_id, actor_id, role) VALUES ('tenant',"
|
||||
+ " 'workspace', ?, ?)",
|
||||
("actor-" + canonical).getBytes(StandardCharsets.UTF_8),
|
||||
canonical);
|
||||
}
|
||||
assertThat(jdbc.queryForList("SELECT DISTINCT role FROM"
|
||||
+ " managed_workspace_access", String.class))
|
||||
.containsExactlyInAnyOrder("READER", "OPERATOR", "OWNER");
|
||||
for (String rejected : List.of("NONE", "SPECTATOR", "reader",
|
||||
"READER ", "OPERATOR ", "OWNER ", "REA DER")) {
|
||||
assertThatThrownBy(() -> jdbc.update("INSERT INTO"
|
||||
+ " managed_workspace_access (tenant_id, workspace_id,"
|
||||
+ " actor_id, role) VALUES ('tenant', 'workspace', ?,"
|
||||
+ " ?)", "padded".getBytes(StandardCharsets.UTF_8),
|
||||
rejected))
|
||||
.as("role %s must be rejected", rejected)
|
||||
.isInstanceOf(DataAccessException.class)
|
||||
.hasMessageContaining("managed_workspace_access_role");
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM"
|
||||
+ " managed_workspace_access WHERE actor_id = ?",
|
||||
Integer.class, "padded".getBytes(StandardCharsets.UTF_8)))
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -79,7 +79,8 @@ class WorkspaceLifecycleStoreTest {
|
||||
}
|
||||
var operation = fixture.admit(OperationKind.DELETE);
|
||||
if (draining) fixture.jdbc.update("UPDATE qwen_runtime_harness_drain SET phase = 'DRAINING'");
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET can_create = ?", granted);
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET role = ?",
|
||||
granted ? "OPERATOR" : "READER");
|
||||
ObjectNode next = execution.deepCopy();
|
||||
hookState(next, after, registration.get("catalogRef"));
|
||||
if (replacement && !batch) next.withObject("/run/runtime").put("generation", "2");
|
||||
@@ -402,10 +403,10 @@ class WorkspaceLifecycleStoreTest {
|
||||
.hasMessageContaining("closed");
|
||||
var execution = new WorkspaceExecutionStore(fixture.jdbc, new DataSourceTransactionManager(fixture.jdbc.getDataSource()));
|
||||
execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session));
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE");
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET role = 'READER'");
|
||||
assertThatThrownBy(() -> execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session)))
|
||||
.hasMessageContaining("unavailable");
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE");
|
||||
fixture.jdbc.update("UPDATE managed_workspace_access SET role = 'OPERATOR'");
|
||||
execution.authorizeLegacyClose(fixture.store.requireSession("tenant", fixture.session));
|
||||
fixture.jdbc.update("UPDATE managed_agent_operation SET lease_until = 0");
|
||||
assertThatThrownBy(() -> fixture.transactions.executeWithoutResult(ignored -> journal.authorizeOrdinary("tenant", fixture.session,
|
||||
@@ -554,8 +555,8 @@ class WorkspaceLifecycleStoreTest {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation, storage_id,"
|
||||
+ " display_name, config_ref, policy_ref, state) VALUES ('tenant', 'workspace', 1, 'storage', 'Workspace', ?, ?, 'ACTIVE')",
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES ('tenant', 'workspace', ?, TRUE, TRUE)", "owner".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES ('tenant', 'workspace', ?, 'OPERATOR')", "owner".getBytes(StandardCharsets.UTF_8));
|
||||
session = transactions.execute(ignored -> store.insertWorkspaceSessionCommand("tenant", "owner", "create", "digest", "qwen-code",
|
||||
null, null, List.of(), null, new WorkspaceSelection("workspace", ".")).sessionId());
|
||||
}
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ class WorkspaceMigrationMySqlIT {
|
||||
+ " WHERE installed_rank <= ? ORDER BY installed_rank", lastRank)).isEqualTo(applied);
|
||||
assertThat(jdbc.queryForList("SELECT version FROM flyway_schema_history"
|
||||
+ " WHERE installed_rank > ? AND success = TRUE ORDER BY installed_rank",
|
||||
String.class, lastRank)).containsExactly("48", "49", "50", "51", "52");
|
||||
String.class, lastRank)).containsExactly("48", "49", "50", "51", "52", "53");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM managed_workspace_migration", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_runtime_storage_fence", Integer.class)).isZero();
|
||||
}
|
||||
@@ -116,8 +116,8 @@ class WorkspaceMigrationMySqlIT {
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state)"
|
||||
+ " VALUES (?, 'workspace', 1, 'storage', 'Workspace', ?, ?, 'ACTIVE')", tenant,
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES (?, 'workspace', ?, TRUE, TRUE)", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, 'workspace', ?, 'OPERATOR')", tenant, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
var transaction = new TransactionTemplate(new DataSourceTransactionManager(data));
|
||||
transaction.setIsolationLevel(Connection.TRANSACTION_REPEATABLE_READ);
|
||||
|
||||
+2
-2
@@ -60,8 +60,8 @@ class WorkspaceRecoveryStoreTest {
|
||||
jdbc.update("INSERT INTO managed_workspace_registry (tenant_id, workspace_id, workspace_generation,"
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state) VALUES ('tenant', ?, 1, 'storage',"
|
||||
+ " 'Test', ?, ?, 'ACTIVE')", workspace, WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create)"
|
||||
+ " VALUES ('tenant', ?, ?, TRUE, TRUE)", workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES ('tenant', ?, ?, 'OPERATOR')", workspace, "actor".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
guard.register("tenant", "storage", UUID.randomUUID().toString());
|
||||
String fence = UUID.randomUUID().toString();
|
||||
|
||||
+4
-4
@@ -334,7 +334,7 @@ class WorkspaceStorageGuardTest {
|
||||
+ " VALUES ('tenant', ?, 'create-1', 'digest', ?, 0)",
|
||||
"owner".getBytes(java.nio.charset.StandardCharsets.UTF_8), sessionId);
|
||||
jdbc.update("INSERT INTO managed_workspace_access (tenant_id, workspace_id, actor_id,"
|
||||
+ " can_read, can_create) VALUES ('tenant', 'workspace', ?, TRUE, TRUE)",
|
||||
+ " role) VALUES ('tenant', 'workspace', ?, 'OPERATOR')",
|
||||
"owner".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var session = new SessionRecord("tenant", sessionId, "qwen-code", null,
|
||||
null, "ACTIVE", null, null, 0, 0, 0, 1, 1, null, 1,
|
||||
@@ -359,7 +359,7 @@ class WorkspaceStorageGuardTest {
|
||||
.put("inputRevision", 1).put("policyRevision", "policy");
|
||||
doAnswer(invocation -> {
|
||||
invocation.callRealMethod();
|
||||
assertThat(jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE"
|
||||
assertThat(jdbc.update("UPDATE managed_workspace_access SET role = 'READER'"
|
||||
+ " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'")).isEqualTo(1);
|
||||
return null;
|
||||
}).doCallRealMethod().when(execution).authorizeActionResponse(session);
|
||||
@@ -370,7 +370,7 @@ class WorkspaceStorageGuardTest {
|
||||
});
|
||||
// A revoked creation grant is operator-reversible: the refusal must
|
||||
// stay retryable so a restored grant still delivers the answer.
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = FALSE"
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'READER'"
|
||||
+ " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'");
|
||||
assertThatThrownBy(() -> connector.resolveAction("tenant", sessionId, "action", response))
|
||||
.isInstanceOfSatisfying(RuntimeBrokerException.class, error -> {
|
||||
@@ -378,7 +378,7 @@ class WorkspaceStorageGuardTest {
|
||||
assertThat(error.isRetryable()).isTrue();
|
||||
});
|
||||
// So is a registry that left ACTIVE.
|
||||
jdbc.update("UPDATE managed_workspace_access SET can_create = TRUE"
|
||||
jdbc.update("UPDATE managed_workspace_access SET role = 'OPERATOR'"
|
||||
+ " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'");
|
||||
jdbc.update("UPDATE managed_workspace_registry SET state = 'DRAINING'"
|
||||
+ " WHERE tenant_id = 'tenant' AND workspace_id = 'workspace'");
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ public final class GrantedWorkspaceAccessPolicy
|
||||
"workspaceId"));
|
||||
if (access == null || access == WorkspaceAccess.NONE) {
|
||||
throw new IllegalArgumentException(
|
||||
"access must be READ or CREATE");
|
||||
"access must be READER, OPERATOR or OWNER");
|
||||
}
|
||||
if (grants.putIfAbsent(key, access) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
|
||||
+18
-5
@@ -1,16 +1,29 @@
|
||||
package com.alibaba.qwen.code.runtimebroker.managedworkspace;
|
||||
|
||||
/** An actor's access to one Workspace. {@link #CREATE} implies {@link #READ}. */
|
||||
/**
|
||||
* An actor's role on one Workspace. {@link #OWNER} implies {@link #OPERATOR}
|
||||
* implies {@link #READER}. {@link #NONE} is the no-grant decision only; the
|
||||
* store's CHECK keeps it out of persisted rows.
|
||||
*/
|
||||
public enum WorkspaceAccess {
|
||||
NONE,
|
||||
READ,
|
||||
CREATE;
|
||||
READER,
|
||||
OPERATOR,
|
||||
OWNER;
|
||||
|
||||
/** Whether this access meets {@code required} or ranks above it. */
|
||||
public boolean atLeast(WorkspaceAccess required) {
|
||||
if (required == null) {
|
||||
throw new IllegalArgumentException("required access is required");
|
||||
}
|
||||
return compareTo(required) >= 0;
|
||||
}
|
||||
|
||||
public boolean canRead() {
|
||||
return this != NONE;
|
||||
return atLeast(READER);
|
||||
}
|
||||
|
||||
public boolean canCreate() {
|
||||
return this == CREATE;
|
||||
return atLeast(OPERATOR);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -83,7 +83,7 @@ public final class WorkspaceCatalog {
|
||||
}
|
||||
WorkspaceView defaultView = usableDefault(actor)
|
||||
.map(workspace -> new WorkspaceView(workspace,
|
||||
WorkspaceAccess.CREATE))
|
||||
WorkspaceAccess.OPERATOR))
|
||||
.orElse(null);
|
||||
return new WorkspacePage(views, hasMore, defaultView);
|
||||
}
|
||||
|
||||
+21
-21
@@ -19,16 +19,16 @@ class GrantedWorkspaceAccessPolicyTest {
|
||||
void grantsOnlyTheExactTenantActorAndWorkspace() {
|
||||
GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy
|
||||
.builder()
|
||||
.grant(TENANT, "alice", "ws-a", WorkspaceAccess.CREATE)
|
||||
.grant(TENANT, "bob", "ws-a", WorkspaceAccess.READ)
|
||||
.grant(TENANT, "alice", "ws-a", WorkspaceAccess.OPERATOR)
|
||||
.grant(TENANT, "bob", "ws-a", WorkspaceAccess.READER)
|
||||
.build();
|
||||
|
||||
assertEquals(WorkspaceAccess.CREATE,
|
||||
assertEquals(WorkspaceAccess.OPERATOR,
|
||||
policy.accessFor(new WorkspaceActor(TENANT, "alice"), WORKSPACE));
|
||||
assertEquals(WorkspaceAccess.CREATE, policy.accessFor(
|
||||
assertEquals(WorkspaceAccess.OPERATOR, policy.accessFor(
|
||||
new WorkspaceActor(copy(TENANT), copy("alice")),
|
||||
workspace(copy(TENANT), copy("ws-a"), WorkspaceState.ACTIVE)));
|
||||
assertEquals(WorkspaceAccess.READ,
|
||||
assertEquals(WorkspaceAccess.READER,
|
||||
policy.accessFor(new WorkspaceActor(TENANT, "bob"), WORKSPACE));
|
||||
assertEquals(WorkspaceAccess.NONE,
|
||||
policy.accessFor(new WorkspaceActor(TENANT, "carol"), WORKSPACE));
|
||||
@@ -46,7 +46,7 @@ class GrantedWorkspaceAccessPolicyTest {
|
||||
void neverCrossesTenantsEvenForTheSameIds() {
|
||||
GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy
|
||||
.builder()
|
||||
.grant(OTHER_TENANT, "alice", "ws-a", WorkspaceAccess.CREATE)
|
||||
.grant(OTHER_TENANT, "alice", "ws-a", WorkspaceAccess.OPERATOR)
|
||||
.build();
|
||||
|
||||
assertEquals(WorkspaceAccess.NONE,
|
||||
@@ -62,12 +62,12 @@ class GrantedWorkspaceAccessPolicyTest {
|
||||
void treatsATenantThatDiffersInCaseAsAnotherTenant() {
|
||||
GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy
|
||||
.builder()
|
||||
.grant("TENANT-A", "alice", "ws-a", WorkspaceAccess.CREATE)
|
||||
.grant("TENANT-A", "alice", "ws-a", WorkspaceAccess.OPERATOR)
|
||||
.build();
|
||||
|
||||
assertEquals(WorkspaceAccess.NONE, policy.accessFor(
|
||||
new WorkspaceActor("TENANT-A", "alice"), WORKSPACE));
|
||||
assertEquals(WorkspaceAccess.CREATE, policy.accessFor(
|
||||
assertEquals(WorkspaceAccess.OPERATOR, policy.accessFor(
|
||||
new WorkspaceActor("TENANT-A", "alice"),
|
||||
workspace("TENANT-A", "ws-a", WorkspaceState.ACTIVE)));
|
||||
}
|
||||
@@ -76,30 +76,30 @@ class GrantedWorkspaceAccessPolicyTest {
|
||||
void rejectsAmbiguousOrEmptyGrants() {
|
||||
GrantedWorkspaceAccessPolicy.Builder builder =
|
||||
GrantedWorkspaceAccessPolicy.builder()
|
||||
.grant(TENANT, "alice", "ws-a", WorkspaceAccess.READ);
|
||||
.grant(TENANT, "alice", "ws-a", WorkspaceAccess.READER);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.grant(
|
||||
TENANT, "alice", "ws-a", WorkspaceAccess.CREATE));
|
||||
TENANT, "alice", "ws-a", WorkspaceAccess.OPERATOR));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.grant(
|
||||
TENANT, "alice", "ws-b", WorkspaceAccess.NONE));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> builder.grant(TENANT, "alice", "ws-b", null));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.grant(
|
||||
TENANT, "alice\n", "ws-b", WorkspaceAccess.READ));
|
||||
TENANT, "alice\n", "ws-b", WorkspaceAccess.READER));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.grant(
|
||||
TENANT, "alice", "ws/b", WorkspaceAccess.READ));
|
||||
TENANT, "alice", "ws/b", WorkspaceAccess.READER));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.grant(
|
||||
"tenant/a", "alice", "ws-b", WorkspaceAccess.READ));
|
||||
"tenant/a", "alice", "ws-b", WorkspaceAccess.READER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsABuiltPolicyWhenTheBuilderIsReused() {
|
||||
GrantedWorkspaceAccessPolicy.Builder builder =
|
||||
GrantedWorkspaceAccessPolicy.builder()
|
||||
.grant(TENANT, "alice", "ws-a", WorkspaceAccess.READ);
|
||||
.grant(TENANT, "alice", "ws-a", WorkspaceAccess.READER);
|
||||
GrantedWorkspaceAccessPolicy policy = builder.build();
|
||||
|
||||
builder.grant(TENANT, "alice", "ws-b", WorkspaceAccess.CREATE);
|
||||
builder.grant(TENANT, "alice", "ws-b", WorkspaceAccess.OPERATOR);
|
||||
|
||||
assertEquals(WorkspaceAccess.NONE, policy.accessFor(
|
||||
new WorkspaceActor(TENANT, "alice"),
|
||||
@@ -111,19 +111,19 @@ class GrantedWorkspaceAccessPolicyTest {
|
||||
GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy
|
||||
.builder()
|
||||
.grant(TENANT, "[email protected]", "ws-a",
|
||||
WorkspaceAccess.READ)
|
||||
WorkspaceAccess.READER)
|
||||
.build();
|
||||
|
||||
assertEquals(WorkspaceAccess.READ, policy.accessFor(
|
||||
assertEquals(WorkspaceAccess.READER, policy.accessFor(
|
||||
new WorkspaceActor(TENANT, "[email protected]"), WORKSPACE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createImpliesRead() {
|
||||
assertTrue(WorkspaceAccess.CREATE.canRead());
|
||||
assertTrue(WorkspaceAccess.CREATE.canCreate());
|
||||
assertTrue(WorkspaceAccess.READ.canRead());
|
||||
assertFalse(WorkspaceAccess.READ.canCreate());
|
||||
assertTrue(WorkspaceAccess.OPERATOR.canRead());
|
||||
assertTrue(WorkspaceAccess.OPERATOR.canCreate());
|
||||
assertTrue(WorkspaceAccess.READER.canRead());
|
||||
assertFalse(WorkspaceAccess.READER.canCreate());
|
||||
assertFalse(WorkspaceAccess.NONE.canRead());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ class TextFieldProbeTest {
|
||||
probe("actorId", mismatches, text -> new WorkspaceActor(TENANT, text));
|
||||
probe("grant actorId", mismatches,
|
||||
text -> GrantedWorkspaceAccessPolicy.builder().grant(TENANT,
|
||||
text, "ws-a", WorkspaceAccess.READ));
|
||||
text, "ws-a", WorkspaceAccess.READER));
|
||||
|
||||
assertEquals(List.of(), mismatches);
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.alibaba.qwen.code.runtimebroker.managedworkspace;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class WorkspaceAccessTest {
|
||||
@Test
|
||||
void ranksRolesInGrantOrder() {
|
||||
assertTrue(WorkspaceAccess.OWNER.atLeast(WorkspaceAccess.OPERATOR));
|
||||
assertTrue(WorkspaceAccess.OPERATOR.atLeast(WorkspaceAccess.READER));
|
||||
assertTrue(WorkspaceAccess.READER.atLeast(WorkspaceAccess.READER));
|
||||
assertFalse(WorkspaceAccess.OPERATOR.atLeast(WorkspaceAccess.OWNER));
|
||||
assertFalse(WorkspaceAccess.READER.atLeast(WorkspaceAccess.OPERATOR));
|
||||
assertFalse(WorkspaceAccess.NONE.atLeast(WorkspaceAccess.READER));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WorkspaceAccess.OWNER.atLeast(null));
|
||||
}
|
||||
}
|
||||
+23
-23
@@ -38,15 +38,15 @@ class WorkspaceCatalogTest {
|
||||
|
||||
private static final WorkspaceAccessPolicy GRANTS =
|
||||
GrantedWorkspaceAccessPolicy.builder()
|
||||
.grant(TENANT, "alice", "alpha", WorkspaceAccess.CREATE)
|
||||
.grant(TENANT, "alice", "beta", WorkspaceAccess.CREATE)
|
||||
.grant(TENANT, "alice", "draining", WorkspaceAccess.CREATE)
|
||||
.grant(TENANT, "alice", "readonly", WorkspaceAccess.READ)
|
||||
.grant(TENANT, "alice", "removed", WorkspaceAccess.CREATE)
|
||||
.grant(TENANT, "alice", "alpha", WorkspaceAccess.OPERATOR)
|
||||
.grant(TENANT, "alice", "beta", WorkspaceAccess.OPERATOR)
|
||||
.grant(TENANT, "alice", "draining", WorkspaceAccess.OPERATOR)
|
||||
.grant(TENANT, "alice", "readonly", WorkspaceAccess.READER)
|
||||
.grant(TENANT, "alice", "removed", WorkspaceAccess.OPERATOR)
|
||||
.grant(OTHER_TENANT, "alice", "alpha",
|
||||
WorkspaceAccess.CREATE)
|
||||
WorkspaceAccess.OPERATOR)
|
||||
.grant(OTHER_TENANT, "alice", "gamma",
|
||||
WorkspaceAccess.CREATE)
|
||||
WorkspaceAccess.OPERATOR)
|
||||
.build();
|
||||
|
||||
@Test
|
||||
@@ -129,7 +129,7 @@ class WorkspaceCatalogTest {
|
||||
workspace(TENANT, "peek", WorkspaceState.REMOVED)),
|
||||
Map.of()),
|
||||
GrantedWorkspaceAccessPolicy.builder()
|
||||
.grant(TENANT, "alice", "peek", WorkspaceAccess.READ)
|
||||
.grant(TENANT, "alice", "peek", WorkspaceAccess.READER)
|
||||
.build());
|
||||
|
||||
assertEquals("workspace_not_found", assertThrows(
|
||||
@@ -224,15 +224,15 @@ class WorkspaceCatalogTest {
|
||||
workspace(TENANT, "zz-hidden", WorkspaceState.ACTIVE)),
|
||||
Map.of()),
|
||||
GrantedWorkspaceAccessPolicy.builder()
|
||||
.grant(TENANT, "alice", "alpha", WorkspaceAccess.READ)
|
||||
.grant(TENANT, "alice", "beta", WorkspaceAccess.READ)
|
||||
.grant(TENANT, "alice", "alpha", WorkspaceAccess.READER)
|
||||
.grant(TENANT, "alice", "beta", WorkspaceAccess.READER)
|
||||
.build());
|
||||
WorkspaceCatalog faulty = new WorkspaceCatalog(
|
||||
new FixedRegistry(List.of(
|
||||
workspace(TENANT, "alpha", WorkspaceState.ACTIVE),
|
||||
workspace(OTHER_TENANT, "zeta", WorkspaceState.ACTIVE)),
|
||||
Optional.empty()),
|
||||
(actor, workspace) -> WorkspaceAccess.READ);
|
||||
(actor, workspace) -> WorkspaceAccess.READER);
|
||||
|
||||
assertPage(catalog.list(ALICE, null, 2), false, "alpha", "beta");
|
||||
assertPage(faulty.list(ALICE, null, 1), false, "alpha");
|
||||
@@ -262,12 +262,12 @@ class WorkspaceCatalogTest {
|
||||
@Test
|
||||
void asksThePolicyOnEveryCall() {
|
||||
AtomicReference<WorkspaceAccess> access = new AtomicReference<>(
|
||||
WorkspaceAccess.CREATE);
|
||||
WorkspaceAccess.OPERATOR);
|
||||
WorkspaceCatalog catalog = new WorkspaceCatalog(registry(Map.of()),
|
||||
(actor, workspace) -> access.get());
|
||||
catalog.resolve(ALICE, WorkspaceSelection.explicit("alpha"));
|
||||
|
||||
access.set(WorkspaceAccess.READ);
|
||||
access.set(WorkspaceAccess.READER);
|
||||
|
||||
assertEquals("workspace_forbidden", assertThrows(
|
||||
WorkspaceException.class, () -> catalog.resolve(ALICE,
|
||||
@@ -306,7 +306,7 @@ class WorkspaceCatalogTest {
|
||||
new FixedRegistry(List.of(foreign), Optional.of("alpha")),
|
||||
(actor, workspace) -> {
|
||||
asked.add(workspace);
|
||||
return WorkspaceAccess.CREATE;
|
||||
return WorkspaceAccess.OPERATOR;
|
||||
});
|
||||
|
||||
assertEquals("workspace_not_found", assertThrows(
|
||||
@@ -331,7 +331,7 @@ class WorkspaceCatalogTest {
|
||||
WorkspaceCatalog catalog = new WorkspaceCatalog(
|
||||
new FixedRegistry(List.of(workspace(TENANT, workspaceId,
|
||||
WorkspaceState.ACTIVE)), Optional.of("alpha")),
|
||||
(actor, workspace) -> WorkspaceAccess.CREATE);
|
||||
(actor, workspace) -> WorkspaceAccess.OPERATOR);
|
||||
|
||||
assertEquals("workspace_not_found", assertThrows(
|
||||
WorkspaceException.class, () -> catalog.resolve(ALICE,
|
||||
@@ -352,7 +352,7 @@ class WorkspaceCatalogTest {
|
||||
workspace(TENANT, "_x", WorkspaceState.ACTIVE),
|
||||
workspace(TENANT, "B", WorkspaceState.ACTIVE)),
|
||||
Map.of()),
|
||||
(actor, workspace) -> WorkspaceAccess.CREATE);
|
||||
(actor, workspace) -> WorkspaceAccess.OPERATOR);
|
||||
|
||||
assertPage(catalog.list(ALICE, null, 10), false, "B", "_x", "a");
|
||||
assertPage(catalog.list(ALICE, "B", 10), false, "_x", "a");
|
||||
@@ -367,7 +367,7 @@ class WorkspaceCatalogTest {
|
||||
workspace(TENANT, "b", WorkspaceState.ACTIVE),
|
||||
workspace(TENANT, "a", WorkspaceState.ACTIVE)),
|
||||
Optional.empty()),
|
||||
(actor, workspace) -> WorkspaceAccess.CREATE);
|
||||
(actor, workspace) -> WorkspaceAccess.OPERATOR);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> catalog.list(ALICE, null, 10));
|
||||
@@ -379,7 +379,7 @@ class WorkspaceCatalogTest {
|
||||
workspace(OTHER_TENANT, "zeta", WorkspaceState.ACTIVE),
|
||||
workspace(TENANT, "beta", WorkspaceState.ACTIVE)),
|
||||
Optional.empty()),
|
||||
(actor, workspace) -> WorkspaceAccess.CREATE);
|
||||
(actor, workspace) -> WorkspaceAccess.OPERATOR);
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> mixed.list(ALICE, null, 10));
|
||||
}
|
||||
@@ -397,7 +397,7 @@ class WorkspaceCatalogTest {
|
||||
new ConfiguredWorkspaceRegistry(records, Map.of()));
|
||||
WorkspaceCatalog catalog = new WorkspaceCatalog(registry,
|
||||
GrantedWorkspaceAccessPolicy.builder()
|
||||
.grant(TENANT, "alice", "visible", WorkspaceAccess.READ)
|
||||
.grant(TENANT, "alice", "visible", WorkspaceAccess.READER)
|
||||
.build());
|
||||
|
||||
WorkspacePage page = catalog.list(ALICE, null, 1);
|
||||
@@ -422,14 +422,14 @@ class WorkspaceCatalogTest {
|
||||
|
||||
// Every record is readable: the third one ends the scan.
|
||||
assertPage(new WorkspaceCatalog(dense,
|
||||
(actor, workspace) -> WorkspaceAccess.READ).list(ALICE, null, 2),
|
||||
(actor, workspace) -> WorkspaceAccess.READER).list(ALICE, null, 2),
|
||||
true, "ws-0000", "ws-0001");
|
||||
assertEquals(List.of(1000), dense.limits);
|
||||
// Only the first two are readable: an exact hasMore scans to the end.
|
||||
assertPage(new WorkspaceCatalog(sparse,
|
||||
(actor, workspace) -> workspace.getWorkspaceId()
|
||||
.compareTo("ws-0002") < 0
|
||||
? WorkspaceAccess.READ
|
||||
? WorkspaceAccess.READER
|
||||
: WorkspaceAccess.NONE).list(ALICE, null, 2),
|
||||
false, "ws-0000", "ws-0001");
|
||||
assertEquals(List.of(1000, 1000, 1000), sparse.limits);
|
||||
@@ -456,7 +456,7 @@ class WorkspaceCatalogTest {
|
||||
new InclusiveRegistry(List.of(
|
||||
workspace(TENANT, "a", WorkspaceState.ACTIVE),
|
||||
workspace(TENANT, "b", WorkspaceState.ACTIVE))),
|
||||
(actor, workspace) -> WorkspaceAccess.CREATE);
|
||||
(actor, workspace) -> WorkspaceAccess.OPERATOR);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> catalog.list(ALICE, "a", 10));
|
||||
@@ -469,7 +469,7 @@ class WorkspaceCatalogTest {
|
||||
"policy:strict-7", "bundle@r42");
|
||||
WorkspaceCatalog catalog = new WorkspaceCatalog(
|
||||
new ConfiguredWorkspaceRegistry(List.of(zeta), Map.of()),
|
||||
(actor, workspace) -> WorkspaceAccess.CREATE);
|
||||
(actor, workspace) -> WorkspaceAccess.OPERATOR);
|
||||
|
||||
ResolvedWorkspace resolved = catalog.resolve(
|
||||
new WorkspaceActor("tenant-z", "zed"),
|
||||
|
||||
+13
-13
@@ -16,48 +16,48 @@ class WorkspaceViewTest {
|
||||
|
||||
@Test
|
||||
void showsOnlyTheListedFields() {
|
||||
WorkspaceView view = new WorkspaceView(ALPHA, WorkspaceAccess.CREATE);
|
||||
WorkspaceView view = new WorkspaceView(ALPHA, WorkspaceAccess.OPERATOR);
|
||||
|
||||
assertEquals("alpha", view.getWorkspaceId());
|
||||
assertEquals("Workspace alpha", view.getDisplayName());
|
||||
assertEquals(WorkspaceState.ACTIVE, view.getState());
|
||||
assertTrue(view.canCreateSession());
|
||||
assertFalse(new WorkspaceView(ALPHA, WorkspaceAccess.READ)
|
||||
assertFalse(new WorkspaceView(ALPHA, WorkspaceAccess.READER)
|
||||
.canCreateSession());
|
||||
}
|
||||
|
||||
@Test
|
||||
void comparesByEveryField() {
|
||||
WorkspaceView view = new WorkspaceView(ALPHA, WorkspaceAccess.CREATE);
|
||||
WorkspaceView view = new WorkspaceView(ALPHA, WorkspaceAccess.OPERATOR);
|
||||
|
||||
assertEquals(new WorkspaceView(ALPHA, WorkspaceAccess.CREATE), view);
|
||||
assertEquals(new WorkspaceView(ALPHA, WorkspaceAccess.OPERATOR), view);
|
||||
assertEquals(new WorkspaceView(new WorkspaceRecord(copy(TENANT),
|
||||
copy("alpha"), 1, "storage-alpha",
|
||||
copy("Workspace alpha"), WorkspaceState.ACTIVE,
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.CREATE),
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.OPERATOR),
|
||||
view);
|
||||
assertEquals(new WorkspaceView(ALPHA, WorkspaceAccess.CREATE)
|
||||
assertEquals(new WorkspaceView(ALPHA, WorkspaceAccess.OPERATOR)
|
||||
.hashCode(), view.hashCode());
|
||||
assertNotEquals(new WorkspaceView(ALPHA, WorkspaceAccess.READ), view);
|
||||
assertNotEquals(new WorkspaceView(ALPHA, WorkspaceAccess.READER), view);
|
||||
assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "beta",
|
||||
1, "storage-alpha", "Workspace alpha", WorkspaceState.ACTIVE,
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.CREATE),
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.OPERATOR),
|
||||
view);
|
||||
assertNotEquals(new WorkspaceView(workspace(TENANT, "beta",
|
||||
WorkspaceState.ACTIVE), WorkspaceAccess.CREATE), view);
|
||||
WorkspaceState.ACTIVE), WorkspaceAccess.OPERATOR), view);
|
||||
assertNotEquals(new WorkspaceView(workspace(TENANT, "alpha",
|
||||
WorkspaceState.DRAINING), WorkspaceAccess.CREATE), view);
|
||||
WorkspaceState.DRAINING), WorkspaceAccess.OPERATOR), view);
|
||||
assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "alpha",
|
||||
1, "storage-alpha", "Renamed", WorkspaceState.ACTIVE,
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.CREATE),
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.OPERATOR),
|
||||
view);
|
||||
assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "Alpha",
|
||||
1, "storage-alpha", "Workspace alpha", WorkspaceState.ACTIVE,
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.CREATE),
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.OPERATOR),
|
||||
view);
|
||||
assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "alpha",
|
||||
1, "storage-alpha", "workspace alpha", WorkspaceState.ACTIVE,
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.CREATE),
|
||||
"policy:default", "config:alpha"), WorkspaceAccess.OPERATOR),
|
||||
view);
|
||||
assertFalse(view.equals(null));
|
||||
assertFalse(view.equals("alpha"));
|
||||
|
||||
@@ -1043,7 +1043,7 @@ try {
|
||||
);
|
||||
runMysql(
|
||||
mysqlPort,
|
||||
`INSERT INTO qwen_managed_agent.managed_workspace_access (tenant_id, workspace_id, actor_id, can_read, can_create) VALUES (${sqlString(tenant)}, ${sqlString(boundWorkspaceId)}, ${sqlString(trustedActor)}, TRUE, TRUE)`,
|
||||
`INSERT INTO qwen_managed_agent.managed_workspace_access (tenant_id, workspace_id, actor_id, role) VALUES (${sqlString(tenant)}, ${sqlString(boundWorkspaceId)}, ${sqlString(trustedActor)}, 'OPERATOR')`,
|
||||
);
|
||||
if (inflightFailover) {
|
||||
heldStartProxy = await startHeldExecutionStartProxy(
|
||||
|
||||
Reference in new issue
Block a user