Skip to content

feat(owletto-backend): gate $member list to members, emails to admins#309

Merged
buremba merged 3 commits into
mainfrom
feat/public-org-member-privacy
Apr 22, 2026
Merged

feat(owletto-backend): gate $member list to members, emails to admins#309
buremba merged 3 commits into
mainfrom
feat/public-org-member-privacy

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented Apr 21, 2026

Summary

  • Tightens the public-workspace access policy so PII in the `$member` entity doesn't leak:
    • Non-members of a public org can no longer list or fetch individual members. `manage_entity list/get entity_type=$member` and `resolve_path` both error out with a "join the workspace" message instead of returning sanitized rows.
    • Regular members see the full list but without the `email` field — non-PII metadata (status, role, display_name, …) still renders so the list page has useful columns.
    • Admin/owner callers continue to see everything, including emails.
  • Threads `memberRole` through `ToolContext` so handlers can apply the policy without a separate DB lookup.
  • Adds an integration test covering all four roles (anonymous, authed non-member, regular member, admin).

Pairs with owletto-web#15, which ships the client-side gate + a sticky "Join" bar so the UI never issues these calls as a non-member. This parent PR will get a follow-up submodule-pointer bump once #15 merges.

Why now

PR #296 added self-serve joining for public orgs and sanitized `/public/` REST endpoints, but `manage_entity list` is marked publicly readable at the tool layer (see `auth/tool-access.ts` PUBLIC_READ_ACTIONS). That path bypasses the `/public/` allow-list and returns raw `metadata`, which for `$member` entities includes the user's email.

Test plan

  • `packages/owletto-backend && bun test src/tests/integration/entities/member-email-redaction.test.ts` covers the four role cases.
  • Smoke test on a public workspace via the REST proxy:
    • anonymous `POST /api/{slug}/manage_entity {action:list, entity_type:$member}` → 400 with "only visible to members"
    • authenticated non-member → same 400
    • regular member → 200, no email in metadata
    • owner/admin → 200, email present
  • `GET /{owner}/$member/{name}` via the web app resolves as a non-member → backend rejects; as a member → email field missing; as owner → email present.

…mins

The previous commit on #296 made public workspaces self-serve joinable but
left PII exposed: `manage_entity action=list entity_type=$member` is a
public-readable MCP action, so anonymous or non-member callers hitting
POST /api/{slug}/manage_entity got back every member's email in the
metadata blob. resolve_path on /{owner}/$member/{slug} leaked the same
way.

Policy now enforced in `handleList`, `handleGet`, and `_resolvePath`:

- Non-members of the org cannot see the member list or any individual
  $member entity — both paths throw a "join the workspace" error rather
  than returning a sanitized row, so the very existence of member entries
  isn't catalog-scraped by anonymous callers.
- Members who aren't admin/owner see the full list + entity but get the
  email field stripped from `metadata`. Non-PII fields (status, role,
  display_name, etc.) remain so the list page still has something to
  render.
- Only admin/owner callers see emails.

Threaded `memberRole` through `ToolContext` so handlers can apply the
policy without extra DB lookups; the REST proxy + MCP session path both
populate it from the existing `AuthContext.memberRole`.

Pairs with owletto-web PR #15, which adds the Join bar and gates the
Members/Agents pages client-side so the UI never even issues these calls
as a non-member.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

args.entity_type
? sql`SELECT metadata_schema FROM entity_types WHERE slug = ${args.entity_type} AND organization_id = ${ctx.organizationId} AND deleted_at IS NULL LIMIT 1`.then(
(r) => r[0] ?? null
)
: Promise.resolve(null),

P2 Badge Load $member schema when redacting emails in mixed lists

Email redaction depends on the $member schema (to find the field marked x-email), but this code only fetches metadata_schema when args.entity_type is set. For action: 'list' calls without entity_type, schema becomes null, so redaction falls back to the literal email key and leaks emails for orgs that renamed the member email field (still marked with x-email) to non-admin members.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

env: Env,
ctx: ToolContext
): Promise<ManageEntityResult> {
if (args.entity_type === '$member' && !canSeeMemberList(ctx)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce member gate on unfiltered manage_entity list

The new guard only rejects non-members when args.entity_type === '$member', so a caller can still run manage_entity { action: 'list' } without entity_type and receive $member rows from listEntities (it only filters by type when a type is provided). In a public workspace this lets anonymous/authenticated non-members continue enumerating members, which bypasses the intended “member list is only visible to members” policy.

Useful? React with 👍 / 👎.

Pulls in the client-side half of the public-workspace privacy changes:
sticky Join bar for non-members, Members page gated to members, and
Agents page read-only for non-members — pairs with the backend policy
tightening in the previous commit on this branch.
@buremba buremba enabled auto-merge (squash) April 21, 2026 23:47
…page-auth coverage

etHandleGet now calls ensureMemberEntityType when slug=$member has no row,
unstucking the /devops/%24member page. Adds page-auth-coverage integration
tests covering anon / non-member / member / owner states across the APIs
the workspace pages depend on, with an explicit regression for the
$member auto-provision path.
@buremba buremba merged commit c37c72f into main Apr 22, 2026
12 of 13 checks passed
@buremba buremba deleted the feat/public-org-member-privacy branch April 22, 2026 00:20
buremba added a commit that referenced this pull request Apr 22, 2026
…literals (#311)

#309 added the required memberRole field to ToolContext but missed three
non-MCP callers that build the context manually: public REST endpoints,
connection provisioning, and watcher reaction executor. The Docker build
runs a package-local tsc and caught these; main has been failing the
build-app image since #309 merged.
buremba pushed a commit that referenced this pull request Apr 23, 2026
Second pass on the 2-week PR review. Five more gaps closed:

- gateway: unit tests for verifyOwnedAgentAccess covering owner, cross-tenant,
  cross-platform, agent-scoped, admin-bypass, unknown-agent, and external
  OAuth mismatches (#285 follow-up). Closes the test hole in the cross-tenant
  ownership guard.
- owletto-backend: validate each CSP frame-ancestor entry against a strict
  host-source / scheme-source grammar before joining (#246 follow-up).
  Malformed env entries like `https:// lobu.ai` are now dropped instead of
  silently rendered into the directive.
- owletto-backend: introduce normalizeHost() in utils/public-origin and use
  it from getSubdomainZone, extractSubdomainOrg, getCanonicalRedirectUrl, and
  the BetterAuth trustedOrigins wiring (#234/#224/#214 follow-up). Unifies
  the ad-hoc .toLowerCase()/.replace() patterns and adds IDN→punycode so
  `müller.lobu.ai` matches the ASCII zone configured in env.
- owletto-backend: redact member emails that surface via template_data and
  tab template_data in resolve_path, not only on the single resolved entity
  (#309 follow-up). A dashboard data source that enumerates $member entities
  no longer leaks emails to non-admin callers. New utils/member-redaction
  helper plus unit coverage.
- owletto-backend: treat #311 as already closed — ToolContext.memberRole is
  `string | null` (required, not optional), so TypeScript already catches
  future literal omissions at construction.
buremba pushed a commit that referenced this pull request Apr 23, 2026
Second pass on the 2-week PR review. Five more gaps closed:

- gateway: unit tests for verifyOwnedAgentAccess covering owner, cross-tenant,
  cross-platform, agent-scoped, admin-bypass, unknown-agent, and external
  OAuth mismatches (#285 follow-up). Closes the test hole in the cross-tenant
  ownership guard.
- owletto-backend: validate each CSP frame-ancestor entry against a strict
  host-source / scheme-source grammar before joining (#246 follow-up).
  Malformed env entries like `https:// lobu.ai` are now dropped instead of
  silently rendered into the directive.
- owletto-backend: introduce normalizeHost() in utils/public-origin and use
  it from getSubdomainZone, extractSubdomainOrg, getCanonicalRedirectUrl, and
  the BetterAuth trustedOrigins wiring (#234/#224/#214 follow-up). Unifies
  the ad-hoc .toLowerCase()/.replace() patterns and adds IDN→punycode so
  `müller.lobu.ai` matches the ASCII zone configured in env.
- owletto-backend: redact member emails that surface via template_data and
  tab template_data in resolve_path, not only on the single resolved entity
  (#309 follow-up). A dashboard data source that enumerates $member entities
  no longer leaks emails to non-admin callers. New utils/member-redaction
  helper plus unit coverage.
- owletto-backend: treat #311 as already closed — ToolContext.memberRole is
  `string | null` (required, not optional), so TypeScript already catches
  future literal omissions at construction.
buremba added a commit that referenced this pull request Apr 23, 2026
…instr guard, MCP join rate-limit) (#325)

* fix: address gaps found in post-merge review of last 2 weeks of PRs

Follow-ups from an aggregated teammate review of 128 PRs merged between
2026-04-09 and 2026-04-23. Five concrete gaps patched:

- worker: constrain UploadUserFile to the workspace root (#203 follow-up).
  path.join allowed `../` and absolute paths to escape the workspace. Now
  resolves and rejects anything outside workspaceDir when one is set.
- core: flip Sentry sendDefaultPii to false (#172 follow-up). User content
  and identifiers flow through this stack; the schema has no scrubbing so
  PII-by-default was unsafe.
- gateway: make SlackInstructionProvider extend BaseInstructionProvider
  (#269 follow-up). Sibling Skills/Network providers are wrapped in a
  try/catch that returns "" on error; Slack was bypassing it and would
  crash session-context assembly if listConnections threw.
- owletto-backend: rate-limit the join_organization MCP tool to match the
  REST endpoint (#296 follow-up). Keyed on userId since MCP calls don't
  carry a client IP.

Skipped one reviewer finding: removing the process.env fallback for API
keys at worker.ts:1099/1109 (the inconsistency with #225 base-URL code).
Embedded/dev workers depend on that fallback since credentialStore is
only populated from gateway-supplied session context.

* fix: address remaining gaps from post-merge review

Second pass on the 2-week PR review. Five more gaps closed:

- gateway: unit tests for verifyOwnedAgentAccess covering owner, cross-tenant,
  cross-platform, agent-scoped, admin-bypass, unknown-agent, and external
  OAuth mismatches (#285 follow-up). Closes the test hole in the cross-tenant
  ownership guard.
- owletto-backend: validate each CSP frame-ancestor entry against a strict
  host-source / scheme-source grammar before joining (#246 follow-up).
  Malformed env entries like `https:// lobu.ai` are now dropped instead of
  silently rendered into the directive.
- owletto-backend: introduce normalizeHost() in utils/public-origin and use
  it from getSubdomainZone, extractSubdomainOrg, getCanonicalRedirectUrl, and
  the BetterAuth trustedOrigins wiring (#234/#224/#214 follow-up). Unifies
  the ad-hoc .toLowerCase()/.replace() patterns and adds IDN→punycode so
  `müller.lobu.ai` matches the ASCII zone configured in env.
- owletto-backend: redact member emails that surface via template_data and
  tab template_data in resolve_path, not only on the single resolved entity
  (#309 follow-up). A dashboard data source that enumerates $member entities
  no longer leaks emails to non-admin callers. New utils/member-redaction
  helper plus unit coverage.
- owletto-backend: treat #311 as already closed — ToolContext.memberRole is
  `string | null` (required, not optional), so TypeScript already catches
  future literal omissions at construction.

---------

Co-authored-by: Claude <noreply@anthropic.com>
@buremba buremba restored the feat/public-org-member-privacy branch May 12, 2026 00:23
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