Repository navigation
feat(admin): read-only GET /admin/stats owner list (TC-1482) - #370
Conversation
Adds GET /admin/stats, behind a dedicated NODE_STATS_READ_TOKEN, returning the distinct lowercase owner addresses that hold data beyond the automatic account space. One aggregate statement; a result past its bounds is a 507, never a partial 200.
|
CHANGES NEEDED: see TC-1482 (head 5fcb331) Independent security review (Opus gate) of exact head Blocker (H1): OpenKey's sign-in bootstrap runs the Medium (M1): the 400k bound counts output rows, which today are about 2 per bootstrapped account. So the route returns 507 at ~200k accounts whatever the real owner count. Cost scales with all Low: no minimum token length on a public Auth OK: disabled == unknown 404; the admin secret never opens the route and the stats token opens no Run locally (SQLite + local PostgreSQL 14, no Docker): data_owners 5/5, admin_stats 5/5, admin_stats_logs 3/3, PG round-trip test (1 statement + release ping), EXPLAIN ANALYZE, live-binary curl probes. Not run: MySQL, production data. CI at 5fcb331 when posting: pass: Build dstack ingress image, Version guard, at-rest harness, build (workspace), capabilities, clippy (workspace), fmt (workspace), m1-realdata, plan, separate workspace lockfiles, w5 workspace lockfile. Pending: |
…r (TC-1482) - data_owners: exclude database artifacts in the `account` and `secrets` spaces and KV in `account`, matched on the exact space name, in SQL before the row bound; `secrets` KV and every other space still count. The space bound now counts distinct counted spaces, not sign-in bookkeeping rows. - Replay OpenKey's sign-in bootstrap writes (SQL through SqlService, KV rows) in the core tests and assert a sign-in-only owner is not listed while an owner with a stored secret or data in another space is. - admin_stats: a token shorter than 32 bytes disables the route (404, like an unknown route); tests, README, CHANGELOG and docs/releasing.md updated. - test_support: give PostgreSQL test schemas a process-wide counter so two tests that read the same clock tick cannot collide.
# Conflicts: # CHANGELOG.md
|
APPROVE: see TC-1482 (reviewed exact head e6ed475) The full counting rule in the README ("What is counted") and in the PR body matches Naomi's ruling word for word: live KV in any space except |
TC-1482: wire optional NODE_STATS_READ_TOKEN into the production deploy (companion to #370)
# Conflicts: # CHANGELOG.md
|
APPROVE: see TC-1482 (main-merge only; +/- lines byte-identical vs e6ed475; head a746d2c) Detail: merged origin/main a8ddd90 (1.21.2 + #371 + #372 + #163 pin) into the approved head e6ed475 with a merge commit. The only conflict was CHANGELOG.md (our Unreleased entry vs main's Unreleased + [1.21.2] sections): resolved by keeping both Unreleased entries (main's #371 entry first, then this PR's |
TC-1482 (parent TC-1479). Read-only
GET /admin/statsfor the scoreboard's protocol-user count. Consumer: OpenKey openkey#296 (dba4e5a).Release: targets the next node release after 1.21.2 (not 1.21.2). Opened against
main; do not merge into 1.21.2.What is counted (differs from
usage_bytes > 0)This is the complete counting rule. The README's "Admin stats" section carries the same text.
deleted = false) KV in any space exceptaccount, or database artifacts (SQL/DuckDBdatabase_artifactrows) in any space exceptaccountandsecrets, except KV in thepublicspace whose key starts with.well-known/vault-(vault metadata).accountspace entirely (KV and databases); thesecretstable created by OpenKey's bootstrap (CREATE TABLE secret_records; database artifacts insecrets, while KV stored insecretsstill counts); and the vault metadata written byvault.unlock()(.well-known/vault-pubkey,.well-known/vault-version,.well-known/vault-spaceinpublic). An app may unlock the vault automatically, so counting those rows would inflate "users with data" the same way thesecretstable did. (Naomi's rulings: review round 1 H1 foraccount/secrets, then the vault metadata.)conversationsdatabase, feed-learn's tables) is a person actually using an app that stores on TinyCloud. Artifact rows are never deleted, so a created-then-emptied/dropped database still counts;database_legacy_artifact(fence markers) is not read.Matching is exact. The space name is the last
:segment of the space id (the id parser splits at the last:, so a name never contains one):xsecrets,secrets2,my-account,Secrets,Accountare ordinary spaces (tested, including on SQLite whereLIKEwould be case-insensitive). The vault exclusion is exactly the spacepublicand keys that start with.well-known/vault-, case-sensitive, compared withSUBSTR(key, 1, 18) = '.well-known/vault-'(noLIKE, so no wildcard or case folding). Everything wider still counts: other.well-known/*keys (including.well-known/vault, no dash), any other KV inpublic, the same prefix in any other space,.Well-Known/vault-x. Apublicspace with at least one live KV row that is not vault metadata makes its owner count. So an owner who only signed in, or only signed in and unlocked the vault, is not listed.Owner = the address in
tinycloud:pkh:eip155:<chain>:0x<addr>:<name>, lowercased, deduped across spaces and chains. Every entry is exactly0x+ 40 lowercase hex. Spaces whose owner is not such an address (did:key owner, wrong length/non-hex, non-eip155, unparsable id) are left out.Response
200 application/json,Cache-Control: no-store:{"owners":["0x…"],"count":N,"generatedAt":"<rfc3339>"}, sorted. No truncation flag. OpenKey makes no partial use of a response, so the route never returns a partial 200: more than 200,000 owners (or 400,000 distinct counted spaces, the memory bound, ~110 B each; the excluded sign-in rows are not counted, so the cap no longer fills at ~200,000 signed-in accounts) is a bare 507; a DB failure is a bare 500.The single query
(
tinycloud-core/src/data_owners.rs; owner parsing/dedupe in Rust.) One statement, no per-spacestore_sizeloop (the TC-1274 risk in/admin/usage). Plan on PG14, 20k spaces / 400k kv rows: semi join, onecurrent_kv_pkeyprobe per space that stops at the first live row, index-only scan ofspace, one pass overdatabase_artifact(PK coversspace, no payload read); 85 ms. Runs on SQLite, PostgreSQL and MySQL (no dialect-specific functions). Not wrapped in a read-only transaction: BEGIN/COMMIT would add round trips and the handler issues only this SELECT.The round-trip test counts 2 wire round trips for the request: the statement and sqlx's release ping on every connection return (the harness counts it for all flows), exactly 1 statement, identical with 3 and 303 spaces.
Auth (security-critical)
NODE_STATS_READ_TOKEN, read per request likeTINYCLOUD_ADMIN_SECRET(routes/admin_stats.rs, its own guardStatsAuth).TINYCLOUD_ADMIN_SECRET: route disabled, 404 identical to an unknown route (tested for status, body, content-type, with and without any credential).openssl rand -hex 32(64 hex characters; Bobbie's 32-byte hex token works). Review round 1 L1: a shorter token counts as disabled, because the route is internet-reachable with no rate limit.subtle::ConstantTimeEq(neither content nor length leaks).AdminAuthroute (/admin/quota*,/admin/usage), tested with and without the admin secret configured. GET only.observe_space. The existing request logger logs the route template only.Tests
tinycloud-coredata_owners(SQLite and PostgreSQL): account-only excluded; account + data once; two chains/spaces once; mixed case deduped; deleted-only excluded, live beside tombstone included; artifact-only included; malformed ids excluded; bounds are errors not truncation; owner-parsing cases. New: exact-name exclusion matrix (secretsKV counted,secrets/accountartifacts andaccountKV not;xsecrets,secrets2,my-secrets,Secrets,SECRETS,xaccount,account2,my-account,Account,ACCOUNT,default,applications,publiccounted); distinct-space bound (20 sign-in-only accounts fit a bound of 1).sign_in_only_owners_are_not_listed, both backends): replays OpenKey's bootstrap for 12 accounts (5spacerows each; the 10ACCOUNT_INDEX_SCHEMAstatements, the 5spacesrows and thesecret_recordsschema through the realSqlService+SeaOrmDatabaseArtifactRepository, so thedatabase_artifactrows are the node's own; the 7accountKV rows). It first checks the replay produced 1 artifact inaccount, 1 insecretsand 7accountKV rows per owner, then asserts: sign-in only => not listed; sign-in + vault unlock (the three.well-known/vault-*rows inpublic, checked present and live) for 7 accounts => still not listed; a stored secret (secretsKV), KV indefault, or a SQL table indefault=> listed. Each unlocked account is then given one more row, one at a time, and must appear (and only it):notes/xinpublic;.well-known/vault-pubkeyindefault(prefix in another space);.well-known/otherinpublic;.well-known/vault(no dash) inpublic;.Well-Known/vault-xinpublic(case). A deletednotes/goneinpublicbeside the vault rows does not count. Mutation checks of the vault filter (each fails on SQLite and PostgreSQL): removing it fails the "unlock only" assertion (7 owners wrongly listed); dropping thepublicname condition fails the prefix-in-another-space case; dropping the prefix condition (allpublicKV excluded) fails thenotes/xcase andexclusions_are_exact(publicmust count); widening the prefix to.well-known/fails the.well-known/othercase; a case-insensitive prefix fails the.Well-Known/vault-xcase. KV rows are inserted directly (the KV write path needs signed delegations;data_ownersreads onlyspaceandcurrent_kv).tests/admin_stats.rs(SQLite + PostgreSQL scenario incl. a sign-in-onlysecretsartifact owner and asecretsKV owner; SQLite auth matrix; disabled route == unknown route incl. a 31-byte token with the correct header, a 32-byte token works; 200,000 owners → 200, 200,001 → 507).tests/admin_stats_logs.rs: runs the real binary (normal, debug, trace), asserts neither the token, admin secret, presented wrong token, address nor space id appears in stdout/stderr, and one/admin/statsrequest event per call. Mutation-checked: logging the owner list fails it.tests/postgres_round_trip_budget.rs(existing wire-counting proxy harness): one statement, round trips independent of space count.test_support: PostgreSQL test schema names now carry a process counter; two tests could read the same clock tick (schema ... already exists, seen once in 15 runs; 0 in 40 after).clippy --workspace --all-targets -D warnings,tinycloud-coredata_owners11/11 (SQLite and PostgreSQL 14),admin_stats5/5,admin_stats_logs3/3,postgres_round_trip_budget4/4. The earlier note aboutpostgres_space_filter_survives_a_collation_that_ignores_the_sentinel(needs PG 16 ICUrules) was from the full core lib run, which was not repeated for this change.Measured cost (reviewer)
4.35 s warm on local PostgreSQL 14 at 300,000 spaces / 1.5M keys (50k owners x 6 bootstrap spaces, parallel seq scan of
spaceplus one index-only probe per space). Measured before the vault-metadata condition, which adds one prefix comparison percurrent_kvrow a probe reads; it was not re-measured. The cost follows allspacerows, not only listed owners. No query redesign in this PR; the README documents the scaling limit.Not done / follow-ups
docker.ymland the compose files do not passNODE_STATS_READ_TOKEN, so the route stays 404 in production until a reviewed change adds the secret, thePHALA_ENV_pass-through and a compose line (changes the reviewed compose hash). Documented indocs/releasing.md. That file's bullet ("not passed to the CVM bydocker.yml...") belongs to this PR and becomes stale once the deploy-wiring PR (TC-1482: wire optional NODE_STATS_READ_TOKEN into the production deploy (companion to #370) #371) lands; whoever merges second updates it.log-shipper/fields.json(canonical copy of tinycloud-devops/logging/fields.json) anddeploy/ingress/redacted-logging.confare untouched: the shipper drops an unlisted route and the ingress logs it as<other>, both safe. Add/admin/statsthere in a coordinated change if the route should be visible in shipped logs.docs/releasing.md.Overlap with other open PRs
CHANGELOG.md(top of Unreleased, same spot as #353/#356/#357): textual conflict, trivial.tinycloud-core/src/db.rs: one hunk afterlist_space_ids(~l.722), no overlap with #353/#357.tinycloud-node-server/src/lib.rs: import at ~l.123 and route list at ~l.481; #359's nearest hunk is at l.107 (adjacent, likely clean); #355 touches other lines.