Permissions: tenant-owner admin + My-mailboxes UI (Stage A)

closed
#86021db opened by agent Sep 11

Permission enforcement + role-gated UI (Stage A core)

Parent: bug 3726817 — ATProto identity <-> mailbox mapping. Decision locked: tenant owner (Tenant.OwnerDID) is sole admin for all its domains. Multi-admin deferred.

Goal: Only tenant owners see member lists / Add-mailbox buttons; non-owners get a “My mailboxes” view with IMAP/app-password setup only. Any authenticated DID can no longer manage another DID’s mailbox.

Architecture: Centralize authz in two helpers (RequireTenantOwner, CanAccessMailbox), enforce in XRPC + UI handlers, gate templates by role. App DB stays source-of-truth.

Tech Stack: Go, SQLite (internal/store), XRPC appview, templ UI (internal/appview/ui), authbroker sessions.


Task 1: Authz helpers + store index

Files: - Modify: internal/appview/auth.go - Modify: internal/store/store.go - Modify: internal/store/sqlite.go - Test: internal/appview/appview_test.go (or new internal/appview/auth_test.go) - Test: internal/store/sqlite_test.go

  • [ ] Step 1: Write failing tests for helpers
func TestRequireTenantOwner_ForbidsNonOwner(t *testing.T) {
    // caller DID != tenant.OwnerDID -> expect ErrForbidden
}
func TestCanAccessMailbox_OwnerOrTenantOwnerOnly(t *testing.T) {
    // cases: Account.DID==caller PASS; Tenant.OwnerDID==caller PASS; stranger FAIL
}
func TestListAccountsByDID(t *testing.T) {
    // seed 2 mailboxes with different DIDs, ListAccountsByDID returns only caller's
}
  • [ ] Step 2: Run to verify they fail

Run: go test ./internal/appview/ ./internal/store/ -run 'TestRequireTenantOwner|TestCanAccessMailbox|TestListAccountsByDID' -v Expected: FAIL (helpers / method undefined)

  • [ ] Step 3: Implement helpers + store method
// internal/appview/auth.go
func RequireTenantOwner(ctx context.Context, st store.Store, tenantID string) error {
    did := DIDFromContext(ctx)
    tn, err := st.GetTenant(ctx, tenantID)
    if err != nil { return err }
    if tn.OwnerDID != did { return ErrForbidden } // map to 403 in handlers
    return nil
}
func CanAccessMailbox(ctx context.Context, st store.Store, acct store.Account) error {
    did := DIDFromContext(ctx)
    if acct.DID == did { return nil }
    dom, err := st.GetDomain(ctx, acct.DomainID)
    if err != nil { return err }
    return RequireTenantOwner(ctx, st, dom.TenantID)
}
// internal/store/store.go — add to Store interface:
ListAccountsByDID(ctx context.Context, did string) ([]Account, error)
// internal/store/sqlite.go — implement + migration:
// CREATE INDEX IF NOT EXISTS idx_mailboxes_did ON mailboxes(did);
  • [ ] Step 4: Run tests

Run: go test ./internal/appview/ ./internal/store/ -v Expected: PASS

  • [ ] Step 5: Commit
jj commit -m "feat(authz): tenant-owner helpers + ListAccountsByDID" internal/appview/auth.go internal/store/store.go internal/store/sqlite.go internal/appview/auth_test.go internal/store/sqlite_test.go

Task 2: Gate XRPC endpoints

Files: - Modify: internal/appview/mail_create_account.go (scope did override to tenant owners) - Modify: internal/appview/mail_list_accounts.go, mail_remove_account.go - Modify: internal/appview/mail_create_app_password.go, mail_list_app_passwords.go, mail_remove_app_password.go - Modify: internal/appview/domain_create.go, domain_list.go, domain_get_dns_state.go - Test: internal/appview/mail_create_account_test.go, new internal/appview/authz_test.go

  • [ ] Step 1: Write failing test
func TestCreateAccount_DIDOverrideRequiresTenantOwner(t *testing.T) {
    // caller=alice, in.Did=bob, domain owned by carol-tenant -> expect 403, no row created
}
func TestAppPassword_OtherDIDForbidden(t *testing.T) {
    // caller=stranger calls createAppPassword on alice's account -> 403
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/appview/ -run 'TestCreateAccount_DIDOverride|TestAppPassword_OtherDID' -v Expected: FAIL (currently 200 / no check)

  • [ ] Step 3: Implement gates
// mail_create_account.go — after loading domain+tenant:
if in.Did != "" && in.Did != callerDID {
    if err := RequireTenantOwner(ctx, st, domain.TenantID); err != nil { return forbidden(err) }
}
// account/app-password handlers — after loading account:
if err := CanAccessMailbox(ctx, st, acct); err != nil { return forbidden(err) }
// domain handlers — after loading tenant/domain:
if err := RequireTenantOwner(ctx, st, tenantID); err != nil { return forbidden(err) }
  • [ ] Step 4: Run tests

Run: go test ./internal/appview/ -v Expected: PASS

  • [ ] Step 5: Commit
jj commit -m "fix(authz): scope XRPC did override + mailbox/app-password access" internal/appview/mail_create_account.go internal/appview/mail_list_accounts.go internal/appview/mail_remove_account.go internal/appview/mail_create_app_password.go internal/appview/mail_list_app_passwords.go internal/appview/mail_remove_app_password.go internal/appview/domain_create.go internal/appview/domain_list.go internal/appview/domain_get_dns_state.go internal/appview/authz_test.go

Task 3: Role-gated UI (My mailboxes vs admin)

Files: - Modify: internal/appview/ui/handler.go (home, domainDetail, accountDetail, createAccount, issueAppPassword, deleteAppPassword) - Modify: internal/appview/ui/home.templ, domain.templ, account.templ, view.go / account_templ.go - Test: internal/appview/ui/ui_test.go, hx_handler_test.go

  • [ ] Step 1: Write failing handler test
func TestHome_NonOwnerSeesMyMailboxes(t *testing.T) {
    // session DID=bob owns 1 mailbox on carol's domain, owns no tenant
    // GET / -> expect "My mailboxes" + IMAP link, expect NO "Add a mailbox", NO member list
}
func TestDomainDetail_StrangerForbidden(t *testing.T) {
    // GET /domains/{carol-domain} as bob (owns mailbox but not tenant) -> 403 or redirect, not 200
}
func TestAccountDetail_OwnerAndTenantOwnerAllowed(t *testing.T) {
    // GET .../accounts/{aid} as mailbox owner PASS; as tenant owner PASS; as stranger 403
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/appview/ui/ -run 'TestHome_NonOwner|TestDomainDetail_Stranger|TestAccountDetail_Owner' -v Expected: FAIL

  • [ ] Step 3: Implement UI branching
// handler.go home():
tn, err := st.GetTenantByOwnerDID(ctx, did)
if err != nil || tn == nil {
    boxes, _ := st.ListAccountsByDID(ctx, did) // My mailboxes view
    renderHomeMyMailboxes(w, did, boxes) // + "Host a new domain" CTA (existing POST /domains)
    return
}
// existing tenant-admin home (domains + Add domain)
// domainDetail/accountDetail/createAccount/issue/deleteAppPassword:
acct/dom loaded -> if err := CanAccessMailbox / RequireTenantOwner; err != nil { http.Error 403; return }

Templates: home.templ gets MyMailboxes branch; domain.templ/account.templ render owner DID/handle (placeholder until Issue 2 wires real handles) and hide Add a mailbox unless IsOwner.

  • [ ] Step 4: Run tests

Run: go test ./internal/appview/ui/ -v Expected: PASS

  • [ ] Step 5: Commit
jj commit -m "feat(ui): my-mailboxes view + role-gated domain/account pages" internal/appview/ui/handler.go internal/appview/ui/home.templ internal/appview/ui/domain.templ internal/appview/ui/account.templ internal/appview/ui/view.go internal/appview/ui/ui_test.go

Acceptance (this issue): - Second ATProto login sees only its own mailboxes; can reach IMAP/app-password setup. - Non-owner XRPC/UI manage of another DID’s mailbox → 403 (tested). - Tenant owner flow unchanged (sees domains, member lists, Add mailbox).

2 Comments

agent 8b67092 Sep 11

Implementation Plan (approved)

Parent: bug 3726817. Clarified: “stranger” = authenticated DID with no relation to the target object (not mailbox owner, not tenant owner). First-time logins never 403 — they see My-mailboxes (empty) + Host-new-domain CTA; tenant is created as parent of the first hosted domain, never before.

Goal: Enforce tenant-owner-only admin and give non-owners a “My mailboxes” view.

Architecture: New internal/appview/authz.go holds ErrForbidden, RequireTenantOwner, CanAccessMailbox; XRPC returns 403 Forbidden via xrpc.WriteErr, UI returns 403 via http.Error; ListAccountsByDID + mailboxes(did) index powers the non-admin home branch. Templates gate on new IsAdmin flags.

Tech Stack: Go, SQLite (internal/store), stdlib mux + internal/xrpc, templ UI.


Task 1: Authz helpers

Files: Create internal/appview/authz.go; Test internal/appview/authz_test.go.

  • [ ] Step 1: Write failing test (RequireTenantOwner owner/non-owner, CanAccessMailbox owner/tenant-owner/stranger — see plan for full code).
  • [ ] Step 2: go test ./internal/appview/ -run 'TestRequireTenantOwner|TestCanAccessMailbox' -v → FAIL (undefined).
  • [ ] Step 3: Implement authz.go with ErrForbidden, RequireTenantOwner(ctx, st, tenantID), CanAccessMailbox(ctx, st, acct).
  • [ ] Step 4: Re-run → PASS.
  • [ ] Step 5: jj commit -m "feat(authz): tenant-owner helpers with tests" internal/appview/authz.go internal/appview/authz_test.go

Task 2: Store ListAccountsByDID + index

Files: Modify internal/store/store.go (interface), internal/store/sqlite.go (migrate index + method); Test internal/store/sqlite_test.go.

  • [ ] Step 1: Write TestListAccountsByDID (seed 2 mailboxes, expect only caller’s).
  • [ ] Step 2: go test ./internal/store/ -run TestListAccountsByDID -v → FAIL (undefined).
  • [ ] Step 3: Add interface method; CREATE INDEX IF NOT EXISTS idx_mailboxes_did ON mailboxes(did) in migrate(); implement ListAccountsByDID.
  • [ ] Step 4: go test ./internal/store/ -v → PASS.
  • [ ] Step 5: jj commit -m "feat(store): ListAccountsByDID plus did index" ...

Task 3: Gate XRPC endpoints (403)

Files: mail_create_account.go (scope did override to tenant owners), mail_list_accounts.go, mail_remove_account.go, mail_create_app_password.go, mail_list_app_passwords.go, mail_remove_app_password.go (CanAccessMailbox), domain_create.go, domain_list.go, domain_get_dns_state.go (RequireTenantOwner). Test authz_test.go (append handler tests).

  • [ ] Step 1: Failing tests — DID-override by non-owner → 403 + no write; tenant-owner override → 200; stranger app-password → 403; stranger domain.list → 403.
  • [ ] Step 2: Run → FAIL (currently 200, unscoped).
  • [ ] Step 3: Implement gates (403 Forbidden via xrpc.WriteErr; missing objects keep 400⁄404).
  • [ ] Step 4: go test ./internal/appview/ -v → PASS.
  • [ ] Step 5: Commit.

Task 4: UI home My-mailboxes branch

Files: ui/handler.go (home: no auto-tenant; non-owner → ListAccountsByDID view), ui/view.go (HomePageData += IsAdmin, MyMailboxes), ui/home.templ (branch), Test ui/ui_test.go (+ newTestServerWithDID helper).

  • [ ] Step 1: TestHome_NonOwnerSeesMyMailboxes (bob sees My mailboxes + bob@ only + Host CTA, no tenant auto-created) + TestHome_OwnerKeepsAdminView.
  • [ ] Step 2: Run → FAIL (bob gets auto-created empty tenant today).
  • [ ] Step 3: Implement branch; createDomain keeps ensureTenant (explicit CTA). Regenerate templ.
  • [ ] Step 4: go test ./internal/appview/ui/ -v → PASS.
  • [ ] Step 5: Commit.

Task 5: Gate UI domain/account routes + templates

Files: ui/handler.go (requireTenantOwner/canAccessMailbox helpers + gates on all 7 routes, authz BEFORE Stalwart calls), ui/view.go (DomainPageData/AccountPageData += IsAdmin), ui/domain.templ (hide Add-mailbox unless admin), ui/account.templ (Owner DID line). Test ui/ui_test.go.

  • [ ] Step 1: TestDomainDetail_StrangerForbidden, TestAccountDetail_MailboxOwnerAllowedStrangerForbidden, TestDomainPage_HidesAddMailboxForNonAdmin.
  • [ ] Step 2: Run → FAIL (all 200 today).
  • [ ] Step 3: Implement gates + template flags. Regenerate templ.
  • [ ] Step 4: go test ./internal/appview/ui/ ./internal/appview/ ./internal/store/ -v → PASS.
  • [ ] Step 5: Commit.

Acceptance: second login sees only own mailboxes; non-owner manage → 403 (tested); owner flow unchanged.

agent 896a092 Sep 11

Implemented per plan (5 jj commits, full suite green on fresh run):

  • 5ce5904e feat(authz): RequireTenantOwner + CanAccessMailbox helpers, fail-closed on empty DID
  • 57990286 feat(store): ListAccountsByDID + idx_mailboxes_did index
  • 26196be1 fix(authz): XRPC gates — did override scoped to tenant owners; mailbox/app-password routes owner-or-tenant-owner; domain routes tenant-owner-only; strangers get 403 Forbidden
  • 16527c44 feat(ui): home branches to My-mailboxes + Host-new-domain CTA for DIDs without a tenant; no more auto-tenant on first visit
  • a5458ed6 feat(ui): domain/account/app-password routes gated (authz before Stalwart calls); Add-mailbox hidden unless admin; account page shows Owner DID

Notes: router_test updated (missing tenant on domain.list is now 404, still proving DevDID injection); regenerated templ churn in login_templ.go reverted as unrelated; provision.go gofmt drift is pre-existing, untouched. Acceptance holds: second login sees only own mailboxes; non-owner manage returns 403 (tested); owner flow unchanged. Leaving open for your review.