Skip to content

🏗️ PUT-1736: a team directory apps can read, once the team opens it - #3745

Merged
jfcastro92 merged 1 commit into
mainfrom
juancastro/put-1736-team-directory
Sep 9, 2026
Merged

🏗️ PUT-1736: a team directory apps can read, once the team opens it#3745
jfcastro92 merged 1 commit into
mainfrom
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 94.01%
⬆️ +0.04%
30169 / 32088
🔵 Statements 92.02%
⬆️ +0.04%
32789 / 35630
🔵 Functions 91%
⬆️ +0.04%
5562 / 6112
🔵 Branches 81.03%
⬆️ +0.01%
21746 / 26836
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, 246-248, 496-499, 514
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%
79.45%
🟰 ±0%
94.59%
🟰 ±0%
89.23%
🟰 ±0%
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, 565-568, 584-587, 592, 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 #1759 for commit 4e45fdc 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.73%
⬇️ -0.01%
4621 / 7366
🔵 Statements 61.23%
⬇️ -0.02%
4909 / 8017
🔵 Functions 63.03%
⬇️ -0.09%
873 / 1385
🔵 Branches 54.98%
🟰 ±0%
3056 / 5558
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 #556 for commit 4e45fdc 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).

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from c634771 to 24f11d5 Compare September 4, 2026 19:46
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from 24f11d5 to 3934b1d Compare September 8, 2026 11:41
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from 3934b1d to d09e581 Compare September 8, 2026 21:03
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from d09e581 to c7bc181 Compare September 8, 2026 22:07
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from c7bc181 to 507d7d9 Compare September 9, 2026 13:59
@jfcastro92
jfcastro92 removed this pull request from stack #3753 September 9, 2026 14:08
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from 507d7d9 to a2d3ddc Compare September 9, 2026 15:01
@jfcastro92
jfcastro92 added this pull request to stack #3835 September 9, 2026 15:02
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1736-team-directory branch from a2d3ddc to 52598b9 Compare September 9, 2026 15:55
Base automatically changed from juancastro/put-1740-1762-workspace-gui to main September 9, 2026 18:51
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 52598b9 to 4e45fdc Compare September 9, 2026 18:51
@jfcastro92
jfcastro92 merged commit e132f76 into main Sep 9, 2026
13 checks passed
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.

2 participants