Skip to content

πŸ—οΈ PUT-1736: a team directory apps can read, once the team opens it - #3745

Open
jfcastro92 wants to merge 1 commit into
juancastro/put-1740-1762-workspace-guifrom
juancastro/put-1736-team-directory
Open

πŸ—οΈ PUT-1736: a team directory apps can read, once the team opens it#3745
jfcastro92 wants to merge 1 commit into
juancastro/put-1740-1762-workspace-guifrom
juancastro/put-1736-team-directory

Conversation

@jfcastro92

Copy link
Copy Markdown
Collaborator

Closes PUT-1736. Stacked on #3742.

The delta is one flag, not a new disclosure

The ticket reads as "should colleagues be visible to apps," but /teams/:uid/members already has requireUserActor: true and nothing more β€” any member can already enumerate their colleagues today. The gate is the actor type, not the data.

So the only thing this adds is admitting an app-under-user actor to the same names. That is the whole risk, and it is what the setting governs.

The open question, resolved

The ticket said resolve before building. Chosen: a per-team setting, default off.

Rejected alternatives, and why:

  • Per-app consent β€” there is no existing consent surface to reuse, so it means building one for a single feature. It also asks members a question they cannot answer well ("should Calendar see your colleagues?") and gets clicked through.
  • Open by default β€” the only option that cannot be walked back. Once apps depend on the directory, turning it off is a breaking change.

⚠ Worth a reviewer's eye: the names disclosed belong to the members, and the owner flips the switch. What makes that defensible is that the payload is strictly what colleagues already see, it is off unless deliberately turned on, and turning it on is audited. If that is not enough, the escalation is a member-level opt-out on top β€” deliberately not built, because a setting nobody finds is worse than no setting.

What bounds an app

  • directory_enabled defaults to 0. A team that has not opted in answers 404, not 403 β€” whether a team has this on is not something an app should be able to probe for either.
  • The membership tested is the person's, never the app's. An app installed by a member of team A can never read team B.
  • The page carries username and uuid and nothing else. No email, activation state, usage or role β€” those stay on the admin routes.
  • Suspended and never-activated accounts are excluded.

The bug this nearly shipped with

The obvious test for "never activated" is password IS NOT NULL. That is wrong: a provisioned seat holds its temporary password from birth, so the filter would have admitted exactly the accounts meant to be excluded. Activation is requires_password_change clearing.

I only caught it because the HTTP test asserted the seat was absent and it wasn't. There is now a test that walks a seat through provisioned β†’ activated β†’ suspended and asserts directory membership at each step.

The toggle

In TabTeams, next to the team card. Turning it on asks for confirmation naming what apps will be able to read; turning it off does not β€” one grants access, the other only takes it away. A refused confirmation repaints so the checkbox does not sit checked against the truth.

Both transitions write an audit row (directory_enabled / directory_disabled), and setting it to the value it already has records nothing.

Verification

Typecheck clean, check:puterjs:types clean. Backend 7892 passed | 26 skipped, GUI and SDK 79 passed. All 13 commits in the stack typecheck individually.

Falsified β€” each gate broken, the failing test confirmed, then restored:

Broke Failed
the opt-in check 2 β€” the service test and the HTTP one
the membership requirement 1 β€” an outsider reads the directory
the exclusion filter 1 β€” suspended and unactivated appear

Migration

0082_team-directory.sql / mysql_mig_37 / postgres_mig_26, adding group.directory_enabled with a default of 0. Idempotent on all three, since mysql has no per-file applied-state tracking.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from c43e43d to c1c1fc7 Compare September 4, 2026 13:46
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.9%
⬆️ +0.06%
29165 / 31058
πŸ”΅ Statements 91.92%
⬆️ +0.06%
31660 / 34442
πŸ”΅ Functions 90.65%
⬆️ +0.04%
5376 / 5930
πŸ”΅ Branches 80.78%
⬆️ +0.02%
20930 / 25907
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/clients/database/SqliteDatabaseClient.ts 89.14%
🟰 ±0%
57.45%
🟰 ±0%
81.6%
🟰 ±0%
95.58%
🟰 ±0%
6, 9, 13, 16, 24-26, 32, 33, 34, 38-39, 44, 45, 46, 47, 48, 245-247, 495-498, 513
src/backend/clients/event/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/team/TeamController.ts 85.5%
⬆️ +4.89%
45.39%
⬆️ +2.06%
73.07%
⬆️ +0.53%
89.15%
⬆️ +5.30%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 209, 224, 280, 303-312, 356-358, 371-373, 378-380, 387-389
src/backend/services/share/ShareNotificationService.ts 86.59%
⬆️ +0.68%
79.45%
⬆️ +0.54%
94.59%
🟰 ±0%
89.23%
⬆️ +0.77%
57, 88, 90-92, 101, 114, 128, 182, 204, 212-217, 222-227, 247, 250-251, 322, 325, 388, 414-415, 436-437, 461, 472, 526, 544, 584-587, 592, 599-603, 666-678, 694, 715-716, 728
src/backend/services/share/ShareService.ts 92.52%
🟰 ±0%
83.87%
🟰 ±0%
96.13%
🟰 ±0%
96.29%
🟰 ±0%
48, 84, 106, 108, 118, 120, 129-133, 138, 145-149, 163, 172, 177, 179, 234, 266, 267, 297, 356, 394, 395, 484-486, 518-523, 596, 609, 647, 661, 700, 791, 818, 854, 891, 957, 1046, 1047, 1147, 1151, 1156, 1173, 1176-1178, 1356, 1376, 1441, 1491, 1499-1500, 1510-1511, 1526-1527, 1536, 1549-1550, 1558-1562, 1639-1650, 1687, 1708-1709, 1741-1742, 1813, 1839, 1883-1885, 1906-1908, 1960, 2107-2108
src/backend/services/team/TeamService.ts 89.1%
⬆️ +2.46%
74.58%
⬆️ +2.23%
100%
⬆️ +3.58%
91.39%
⬆️ +2.59%
105, 107, 133-134, 154-155, 176, 244-246, 249-251, 260-272, 301-304, 331, 337-339, 437-440, 448, 454, 495-498, 629-631, 691, 775-778, 874, 893-895, 923
src/backend/stores/share/ShareStore.js 92.27%
🟰 ±0%
81.46%
🟰 ±0%
94.2%
🟰 ±0%
94.25%
🟰 ±0%
289-296, 335, 353, 502-504, 635-637, 756, 782, 851-855
src/backend/stores/team/TeamStore.ts 97.6%
⬆️ +0.15%
87.61%
⬇️ -2.18%
100%
🟰 ±0%
98.03%
⬆️ +0.13%
257, 291, 462, 511
src/backend/stores/user/UserStore.ts 93.8%
🟰 ±0%
86.33%
🟰 ±0%
93.02%
🟰 ±0%
96.27%
🟰 ±0%
17, 19, 124, 306, 401, 429, 512, 517, 532, 550-552, 555, 557
Generated in workflow #1601 for commit 24f11d5 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from c1c1fc7 to 4bca032 Compare September 4, 2026 14:01
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from 4bca032 to 65bfba9 Compare September 4, 2026 14:31
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from 65bfba9 to abf5895 Compare September 4, 2026 15:06
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from abf5895 to c6e2cd7 Compare September 4, 2026 15:27
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
πŸ”΅ Lines 62.44%
⬇️ -0.06%
4538 / 7267
πŸ”΅ Statements 61.01%
⬇️ -0.04%
4823 / 7905
πŸ”΅ Functions 62.92%
⬇️ -0.09%
864 / 1373
πŸ”΅ Branches 55.07%
⬆️ +0.02%
3000 / 5447
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/teams/index.js 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/puter-js/src/modules/teams/listDirectory.js 0% 100% 0% 0% 45-48
src/puter-js/src/modules/teams/update.js 88.88%
⬇️ -11.12%
83.33%
⬇️ -16.67%
100%
🟰 ±0%
100%
🟰 ±0%
20
src/puter-js/src/modules/teams/lib/shapes.js 92.85%
⬇️ -7.15%
80.95%
🟰 ±0%
85.71%
⬇️ -14.29%
92.3%
⬇️ -7.70%
29-32
Generated in workflow #398 for commit 24f11d5 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 requested a review from Salazareo September 4, 2026 16:27
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from c6e2cd7 to c634771 Compare September 4, 2026 19:22
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation β€” the directory, API and GUI

Engine mysql 8, live instance. The migration applied and the column is real:

group.directory_enabled: tinyint(1)
[mysql] applied mysql_mig_37.sql (1 statements)

Every gate, against the running server

=== 5. The directory (PUT-1736) ===
  PASS  closed by default, and 404 not 403 β€” got 404
  PASS  the owner can open it
  PASS  it lists once open β€” members=["dbgm3z6tn","sztrwqpz"]
  PASS  an activated seat is in it
  PASS  it carries username and uuid only
        {"username":"dbgm3z6tn","uuid":"d9dd1e68-9712-4927-b1cf-847d2bacfc48"}
  PASS  a non-member cannot read it even when open
  PASS  a suspended account drops out of it

The payload line is the one worth reading twice: the response carries username and uuid and nothing else β€” no email, no org_owned, no activation state. That is asserted against the shape, not eyeballed.

The activation distinction, exercised live

The bug this PR nearly shipped with was testing "never activated" as password IS NOT NULL. A provisioned seat holds its temporary password from birth, so that filter would have admitted exactly the accounts meant to be excluded.

The live run walks a seat through all three states and checks the directory at each:

  • provisioned, not activated β†’ absent
  • activated (chose its own password) β†’ present
  • suspended β†’ absent again

Confirmed on real data rather than fixtures, and the API run separately shows a provisioned seat has email_confirm_code: NULL β€” the administrator made the account, so no confirmation mail exists. That is why activation has to be the forced-change flag clearing and cannot be inferred from the credential.

The toggle in the console

Playwright, real browser:

PASS  the directory panel renders
PASS  the directory toggle is rendered
PASS  turning it on asks first, naming what apps will read

Screenshot 03-admin-directory-confirm shows the dialog:

Apps your members install will be able to read the usernames of everyone active in this team. They will not see emails, records, or suspended accounts. You can turn this off again at any time.

One fix came out of looking at it. The confirm button was styled danger (red), which reads as irreversible and put it in the same visual class as Delete account β€” while the dialog's own last sentence says it is reversible. Now primary.

Verification

Typecheck clean Β· backend 7897 passed | 26 skipped Β· GUI+SDK 275 passed Β· all 13 commits typecheck individually.

Falsified earlier and still standing: the opt-in check (2 tests), the membership requirement (1), the exclusion filter (1).

Members can already enumerate each other: `/teams/:uid/members` needs a user
actor and nothing more. The only thing this adds is admitting an app actor to
the same names, so an app can offer colleagues without the member driving it.

That is the whole risk, so it is off until the team owner turns it on.
`group.directory_enabled` defaults to 0, and a team that has not opted in
answers 404 rather than 403 -- whether a team has this on is not something an
app should be able to probe for either.

Three things bound what an app sees. The membership tested is always the
person's, never the app's, so an app installed by a member of one team can
never read another's. The page carries username and uuid and nothing else --
no email, activation state, usage or role. And suspended accounts and ones
that never took up their credential are left out, since offering someone who
cannot sign in is noise and their existence is not this list's to disclose.

Activation is the forced-change flag clearing, not the password existing: a
provisioned seat holds its temporary password from birth, so testing
`password IS NOT NULL` would have leaked exactly the accounts meant to be
excluded. A test covers that distinction.

Turning the directory on or off writes an audit row, because it changes who
can read the member list and that is not something a team should be able to
alter silently. Setting it to the value it already has records nothing.

The toggle lives in TabTeams, and turning it on asks for confirmation while
turning it off does not -- one grants access, the other only takes it away.

Closes PUT-1736.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from c634771 to 24f11d5 Compare September 4, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant