Permissions: tenant-owner admin + My-mailboxes UI (Stage A)
closedPermission 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
Implementation Plan (approved)
Goal: Enforce tenant-owner-only admin and give non-owners a “My mailboxes” view.
Architecture: New
internal/appview/authz.goholdsErrForbidden,RequireTenantOwner,CanAccessMailbox; XRPC returns403 Forbiddenviaxrpc.WriteErr, UI returns 403 viahttp.Error;ListAccountsByDID+mailboxes(did)index powers the non-admin home branch. Templates gate on newIsAdminflags.Tech Stack: Go, SQLite (
internal/store), stdlib mux +internal/xrpc, templ UI.Task 1: Authz helpers
Files: Create
internal/appview/authz.go; Testinternal/appview/authz_test.go.go test ./internal/appview/ -run 'TestRequireTenantOwner|TestCanAccessMailbox' -v→ FAIL (undefined).authz.gowithErrForbidden,RequireTenantOwner(ctx, st, tenantID),CanAccessMailbox(ctx, st, acct).jj commit -m "feat(authz): tenant-owner helpers with tests" internal/appview/authz.go internal/appview/authz_test.goTask 2: Store ListAccountsByDID + index
Files: Modify
internal/store/store.go(interface),internal/store/sqlite.go(migrate index + method); Testinternal/store/sqlite_test.go.TestListAccountsByDID(seed 2 mailboxes, expect only caller’s).go test ./internal/store/ -run TestListAccountsByDID -v→ FAIL (undefined).CREATE INDEX IF NOT EXISTS idx_mailboxes_did ON mailboxes(did)inmigrate(); implementListAccountsByDID.go test ./internal/store/ -v→ PASS.jj commit -m "feat(store): ListAccountsByDID plus did index" ...Task 3: Gate XRPC endpoints (403)
Files:
mail_create_account.go(scopedidoverride 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). Testauthz_test.go(append handler tests).403 Forbiddenviaxrpc.WriteErr; missing objects keep 400⁄404).go test ./internal/appview/ -v→ PASS.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), Testui/ui_test.go(+newTestServerWithDIDhelper).TestHome_NonOwnerSeesMyMailboxes(bob sees My mailboxes + bob@ only + Host CTA, no tenant auto-created) +TestHome_OwnerKeepsAdminView.createDomainkeepsensureTenant(explicit CTA). Regenerate templ.go test ./internal/appview/ui/ -v→ PASS.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). Testui/ui_test.go.TestDomainDetail_StrangerForbidden,TestAccountDetail_MailboxOwnerAllowedStrangerForbidden,TestDomainPage_HidesAddMailboxForNonAdmin.go test ./internal/appview/ui/ ./internal/appview/ ./internal/store/ -v→ PASS.Acceptance: second login sees only own mailboxes; non-owner manage → 403 (tested); owner flow unchanged.
Implemented per plan (5 jj commits, full suite green on fresh run):
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.