Skip to content

Datasources: PATCH with an empty secret replaces the stored credential with an empty one, and the status reason then blames a missing secret row #28928

Closed

Description

@objectstack-fleet

domain:devx#1 · os-warren · session_01JQwxbZpHPpovxXPremPLtv

立卡门: ① 产品缺陷,具名落点 + 复现(reach: 公开入口 HTTP PATCH /api/v1/datasources/:name 实测一次错误结果)
业务理由: 用脚本或 API 轮换数据源密码的管理员,传了空串就会悄悄毁掉可用凭据;当时 /test 仍然 ok,到下次重连或重启才断,statusReason 还把原因说成「secret 行缺失」,排障方向被带偏。不修,外部数据源会在重启后无声失联。
动手的读者: domain:server 席,packages/services/service-datasource/src/admin-routes.ts 的 splitSecret(空串当缺省或 400 拒收),附 HTTP 层 pin。
查重: repo is:issue datasource secret empty 196 命中、datasource PATCH secret 174、splitSecret 0(均含已关);最近邻 #28549(顶层 password 被丢弃,已关)、#28633、#28209、#28435 都不是本现象。

QA-source: #28927 · integration-system.datasource-credential-refusal-matrix · observed beside the clauses

What happens

An admin PATCH /api/v1/datasources/:name whose body carries "secret": "" answers 200 and destroys the datasource's working credential:

  • the server mints a NEW sys_secret holding an empty value (a 43-byte ciphertext), points external.credentialsRef at it, and deletes the previous secret row;
  • statusReason then reads "credential 'sys_secret:…' could not be resolved or decrypted (missing sys_secret row, or the encryption key changed)", which is wrong: the row exists, it just holds an empty value;
  • hasSecret still reads true, and the already-open pool keeps serving (/test stays ok), so the datasource fails only at the next connect or reboot.

{"secret": null} is a no-op, and a real secret re-binds correctly. The console only sends secret when it is non-empty (ui/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx around L450), so only a direct API caller reaches this. Admin-only door; no credential is disclosed.

Measured at framework 7fa9873f (main, 2026-11-02) on a stock showcase boot from a scratch copy, with a scratch PostgreSQL 16 (scram-sha-256).

Reproduction

  1. Boot the showcase (scratch copy) and a PostgreSQL that requires a password.
  2. As admin, POST /api/v1/datasources {"name":"r7pgb","driver":"postgres","config":{"host":"127.0.0.1","port":PGPORT,"database":"qa","username":"qa"},"secret":"REALPASSWORD"} → 201, status ok; POST /api/v1/datasources/r7pgb/test → ok.
  3. PATCH /api/v1/datasources/r7pgb {"secret":""} → 200.
  4. Expected: an empty secret is treated as absent (no change), or refused 400 naming secret.
  5. Actual: GET /api/v1/datasources/r7pgb shows a new credentialsRef; the old sys_secret row is gone and the new one holds an empty value; statusReason names a missing row. Restart the server: the datasource no longer connects.

Mechanism (read at 7fa9873, a guess)

splitSecret in packages/services/service-datasource/src/admin-routes.ts (around L664) turns "" into {value: ""} instead of treating it as absent, so the rotate path runs. A sibling of the literal-mask PATCH recorded on #24579 §7.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: integration-system.datasource-credential-refusal-matrix

    Triage: priority:p2 · domain:server · pm:queue · 2026-11-02T05:07Z

    ⛔ Not a claim.

    • Read fresh. splitSecret (packages/services/service-datasource/src/admin-routes.ts) treats "secret": "" as a value. An admin PATCH therefore rotates the working credential into an empty one, and the status reason then blames a missing row. The door is admin-only and no credential is disclosed, so this is not a SECURITY flag. It is a user-visible behaviour defect that breaks the datasource at the next reconnect, so it is p2 in domain:server, and it goes to pm:queue.

    Generated by Claude Code

  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

    Claim: PM loop round 1 · card thread · 2026-11-02T05:19Z
    Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat); the card thread is session_01FEkQ5mTYb1BTJNRqf3bdJ8 (the #28735 / #28776 / #28831 / #28863 card thread, continuing; it restarts from main only after PR #28955 merges)
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-xqgh8b (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#1
    Ruling-ref: triage#1 graded the card p2 (05:07Z): admin-only, no credential disclosed, not a SECURITY flag. Route (the dispatching seat's call): an empty secret is refused 400 naming secret at the door, on create and update; null stays a no-op. Dispatched in the seat-1 split of the server queue under 「队列进了很多任务,抓紧派发」 (Leehom, 2026-11-02T05:15Z) and 「定级你可以自己决定」 (Leehom, 2026-11-01T12:11Z).
    File surface: splitSecret and the create/update doors in packages/services/service-datasource/src/admin-routes.ts, and the credential status reason if an empty stored value is reachable; their tests and changesets. ⛔ No packages/spec/**, no new error code, no new gate.
    Container & model: S, mode:thread, model and effort as the thread was opened
    Clause-②: no (narrowing) (expected: "secret": "" that rotated the credential into an empty one is refused; the dev measures)
    Metadata-protocol: per AGENTS.md, measured by the dev (likely none)
    Responsibility: the code is the datasource admin doors of @objectstack/service-datasource · the platform path is an admin PATCH that silently replaces a working credential with an empty one · reached by every direct API caller of PATCH /api/v1/datasources/:name
    Thread-read: none
    Premise: holds on main 7fa9873f (the card's PostgreSQL repro)
    Serial constraints cleared: PR #28955 (this thread's previous card) must merge before the branch restarts. No open server card edits admin-routes.ts.
    Resume-from: none — fresh from origin/main

    State: pm:queue → pm:dispatched, assigned to os-project-manager, recorded by this claim.


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-project-manager · session_01FEkQ5mTYb1BTJNRqf3bdJ8

    os-dev-report
    {
    "issue": 28928,
    "status": "done",
    "branch": "claude/project-thread-xqgh8b",
    "pr": "#29064",
    "session": "session_01FEkQ5mTYb1BTJNRqf3bdJ8",
    "premise_still_valid": true,
    "summary": "POST /api/v1/datasources and PATCH /api/v1/datasources/NAME refuse a secret whose value is the empty string ("" or {value:""}) with 400 DATASOURCE_ADMIN_ERROR naming secret, before anything is written: no sys_secret row, no credentialsRef repoint, no removeSecret, no record put. assertSecretNotEmpty sits in datasource-admin-service.ts beside the #28549/#28612 undeclared-key and inline ssl.key refusals and reuses their shape (plain Error answered by badRequest); the doors have no VALIDATION_ERROR, no new code (seat confirmed). null, absent and whitespace-only secrets unchanged (whitespace is sealed and resolve() returns it as sent, so it is usable; seat confirmed not refusing it). Ruling 4: an empty stored row is reachable (legacy rows; resolve() returns ''), so DatasourceConnectionService.connect now says the credential is empty instead of blaming a missing row or a changed key; still failed-credentials, no driver built. Changeset patch on service-datasource, Clause-②: no (narrowing), Metadata-protocol: none, BREAKING banner, ADR-0087 not-required (no-migration-prescription). No packages/spec, no new gate.",
    "step1_measurement": "main f7c545a, real routes over the real service: "" create 201 seals empty and binds it; "" update 200 writes a new sys_secret holding '', repoints credentialsRef, removes the working row; " " sealed as the 3-space value on both, resolve() returns it unchanged; null create binds no ref, update no-op. Callers: none sends an empty secret on purpose (console DatasourceResourcePage.tsx sets body.secret only when truthy, save and test; client SDK has no datasource create/update; hotcrm none).",
    "tests": "New datasource-empty-secret-refusal.test.ts (real routes, real service, in-memory stores): PATCH "" and {value:''} refused 400 naming secret, record equals seed, nothing written, zero seam calls; POST "" same, nothing stored; controls: real secret rotates on PATCH (new ref, working ref removed) and binds on POST, null and absent on PATCH change no credential, null on POST binds no ref. New D3 case in datasource-connection-service.test.ts: resolver '' fails closed with the empty-credential reason. Base (source at f7c545a, tests at head): Tests 4 failed | 107 passed (111), the 3 refusal pins got {status:200,success:true}, the reason pin got the 'could not be resolved or decrypted' text; 5 controls green. Head 111/111. Ablation (ablation-replace, guard replaced by return;): Tests 3 failed | 5 passed (8), exactly the 3 refusal pins; restore blob = HEAD 1933a09e, git diff HEAD empty. At 66a746c: service-datasource Test Files 69 passed, Tests 1152 passed; typecheck (tsc over src, tests included) exit 0; repo lint command scoped to packages/services/service-datasource exit 0.",
    "gates": "dispatch-gates --commands derived 62 at 66a746c; all 62 exit 0 (record pm-orders/server/28928-gates-ran.txt); --ran: 62 derived, 62 run, 0 NOT-MEASURED; check:type-check-debt OK. CI families: see PR #29064's check-runs.",
    "dogfood_battery": "owed (! title, (narrowing) arm); once at final head 66a746c after turbo build --filter=@objectstack/dogfood... (exit 0): pnpm --filter @objectstack/dogfood test, Test Files 383 passed | 3 skipped (386), Tests 3163 passed | 28 skipped (3191), exit 0.",
    "mcp_calls": "3 — read only: issue_read x3 (#28928 get, #28928 comments, #28927 get). No write tool.",
    "api_writes": "3 — git push (claude/project-thread-xqgh8b restarted from origin/main 00d0668 after PR #28955 merged; the remote branch had been deleted, so a plain new-branch push to 66a746c), relay pr_create (#29064, draft, no assignees, no labels: a changeset is present), and this os-dev-report comment through post-stamped.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: b · reach: named producer: migrateCredential drop-inline refusal (service-datasource datasource-admin-service.ts, the resolved == null || resolved === '' check) says the existing ref 'could not be resolved or decrypted (a missing sys_secret row, or a changed encryption key)' when the row decrypts to empty; outcome right, cause text conflates · evidence: read at 66a746c · dedupe words: migrateCredential empty credential could not be resolved · carrier: seat 1 · noted, not filed"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

    Release: server seat 1 shift close · 2026-11-02T07:38Z
    Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat that claimed this card); card thread session_01FEkQ5mTYb1BTJNRqf3bdJ8 (paused)
    Who / what / where: Leehom (os-project-manager) in the server-lane project chat — 「进展中的开发先提交代码然后暂停,先确保已有的pr都合并」 (2026-11-02T07:11Z), 「自动合并中的也交给下一任跟进」 (2026-11-02T07:23Z), 「我说的是pm-dispatch 中车道下班的流程,都要走完」 (2026-11-02T07:33Z). Shift close returns every claim to the queue (seat-post-protocol.md 交接收尾清单 step 2); no stay-behind.
    Resume-from: claude/project-thread-xqgh8b@66a746c6f45bcac47f811b0408ad2e341306d7fa — PR #29064, ACCEPTed by the seat on this head. Enqueued for merge at handoff; auto-merge not armed at release time — if it left the queue, re-arm.
    Successor: read the handoff 6571543819 on seat post #6367 first; land PR #29064 on green, write the landing record, close this card. ⛔ Do not re-dispatch the card from scratch.

    State: pm:dispatched → pm:queue, assignee os-project-manager cleared, recorded by this release.


    Generated by Claude Code

  5. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

    Claim: PM loop round 1 · takeover of a released card · 2026-11-02T08:16Z
    Session: session_01ADHkmQjPiXgNoSaEpqeGvY
    Account: os-steve (the card's assignee, set in the same round)
    Branch: claude/project-thread-xqgh8b
    Worktree: the predecessor card thread's (paused); no new dev is dispatched
    Domain: domain:server
    Seat: domain:server#1
    File surface: unchanged from the predecessor's claim 6569714758 (packages/services/service-datasource/src/admin-routes.ts); the open PR #29064 is the surface.
    Container & model: S, mode:thread, landing only (no new build)
    Clause-②: no (narrowing)
    Metadata-protocol: none
    Responsibility: as in the predecessor's claim (unchanged)
    Thread-read: 6571729059
    Premise: holds (PR #29064 ACCEPTed by the predecessor seat)
    Serial constraints cleared: none new; the seven PRs this seat inherited are its only in-flight surface.
    Resume-from: claude/project-thread-xqgh8b@66a746c6f45bcac47f811b0408ad2e341306d7fa

    Takeover record: the predecessor (os-project-manager, session_01R1q2xdtYgU6FKrZ7LgjPCC) released this card in 6571729059 at its shift close (Leehom: 「自动合并中的也交给下一任跟进」). This seat takes it on the maintainer's summons in the Claude Code project "objectstack spec" (「server 1 号座位你也接手开席位」), seat 2 (#20966) having left it to seat 1. Last pushed head: 66a746c6f4 (PR #29064). In the merge queue since 2026-11-02T07:01Z; follow to MERGED.

  6. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

    Landed · 2026-11-02T08:34Z

    Probe Reading
    PR #29064 MERGED 2026-11-02T08:21:42Z, merge commit 0a41075ad1, through the merge queue (queued by the predecessor seat)
    #28928 closed completed by Fixes #28928

    pm:dispatched is removed. Seat record: contract review PASS 6571023263, ACCEPT 6571043498 (predecessor), takeover claim 6572358100.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:databugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions