mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
Slice 1 + 3 of the user-management feature from the May 12 plan.
Backend-only - no UI yet (slice 2 ships separately). Backend +
accept-handler together so the email accept link is functional
from day one without a half-state.
Endpoints added:
- GET /api/workspaces/:id/members (any member; via_org=true
for org-level entries,
read-only from ws context)
- GET /api/workspaces/:id/invites (workspace_admin)
- POST /api/workspaces/:id/invites (workspace_admin)
- DELETE /api/workspaces/:id/invites/:inviteId (workspace_admin)
- PUT /api/workspaces/:id/members/:userId (workspace_admin)
- DELETE /api/workspaces/:id/members/:userId (workspace_admin)
- POST /api/auth/accept-invite/:inviteId (requireAuth +
case-insensitive
email match)
Permission gating:
- canAdminWorkspace (existing) for admin-gated endpoints
- canAccessWorkspace (new helper in lib/permissions.js) for the
members read endpoint - mirrors canAdminWorkspace shape but
admits any workspace_members role plus org/platform paths
Security additions vs the original plan:
- Transaction-bounded collision check on POST /invites closes the
TOCTOU race between simultaneous duplicate POSTs (no UNIQUE
constraint on workspace_invites(workspace_id, email))
- Per-(inviter, workspace), hour-window rate limit on POST /invites
to prevent abuse / cost runaway. Env-configurable via
INVITE_RATE_LIMIT_PER_HOUR with conservative 50/hour default.
429 response is generic - does not echo the configured value.
- Invite expiry env-configurable via INVITE_EXPIRY_DAYS (default 7)
- PUBLIC_URL env var (optional) pins the accept-URL origin in prod;
falls back to request-derived for local dev
Rollback rule on email send: only graph_error (real send attempt
failed at Graph) deletes the row and returns 502. not_configured
and dev_restricted are intentional non-sends - keep the row, count
against rate limit, allow local accept-invite testing to proceed.
Other safety blocks:
- Cannot demote/remove the last workspace_admin (409)
- Cannot remove the parent-org's org_owner via workspace path (403)
- Accept-invite is idempotent if user already a member
- Expired invites delete-on-read and return 410
- Wrong-account accept returns 403 without touching the invite
Expired-invite cleanup added to services/heartbeat.js mirroring
the team_invites sweep pattern.
Verification: 9-case curl-driven E2E against the dev DB fixture
(switcher-test + invitee-existing + invitee-new mid-flow register).
All 9 pass: create / collision-409 / second-create / rate-limit-429 /
existing-user-accept / register-then-accept / wrong-account-403 /
expired-410 / viewer-cannot-invite-403.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.8 KiB
JavaScript
139 lines
4.8 KiB
JavaScript
// Phase 2.1: permission helpers.
|
|
//
|
|
// Routes call these as Express middleware to gate access, or as predicate
|
|
// functions to branch within a handler. They presume resolveTenancy has
|
|
// already attached req.workspaceId / req.workspaceRole / req.orgRole /
|
|
// req.isPlatformAdmin.
|
|
//
|
|
// Layering (top wins):
|
|
// 1. req.isPlatformAdmin -> allow anything
|
|
// 2. req.orgRole in {org_owner, org_admin} -> allow read/write/admin within the org
|
|
// org_owner also has billing.write and org.delete (not exposed in 2.1)
|
|
// 3. req.workspaceRole in {workspace_admin, workspace_editor, workspace_viewer}
|
|
// gates resource access per the role's bands
|
|
|
|
'use strict';
|
|
|
|
function canRead(req) {
|
|
if (req.isPlatformAdmin) return true;
|
|
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
|
|
return !!req.workspaceRole; // any workspace_member can read
|
|
}
|
|
|
|
function canWrite(req) {
|
|
if (req.isPlatformAdmin) return true;
|
|
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
|
|
return req.workspaceRole === 'workspace_admin' || req.workspaceRole === 'workspace_editor';
|
|
}
|
|
|
|
function canAdmin(req) {
|
|
if (req.isPlatformAdmin) return true;
|
|
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
|
|
return req.workspaceRole === 'workspace_admin';
|
|
}
|
|
|
|
function isOrgAdmin(req) {
|
|
if (req.isPlatformAdmin) return true;
|
|
return req.orgRole === 'org_owner' || req.orgRole === 'org_admin';
|
|
}
|
|
|
|
function isOrgOwner(req) {
|
|
if (req.isPlatformAdmin) return true;
|
|
return req.orgRole === 'org_owner';
|
|
}
|
|
|
|
// ---- middleware variants ----
|
|
|
|
function requireWorkspace(req, res, next) {
|
|
if (!req.workspaceId) {
|
|
return res.status(403).json({ error: 'No workspace context' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireWorkspaceRead(req, res, next) {
|
|
if (!canRead(req)) {
|
|
return res.status(403).json({ error: 'Workspace access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireWorkspaceWrite(req, res, next) {
|
|
if (!canWrite(req)) {
|
|
return res.status(403).json({ error: 'Workspace editor or admin required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireWorkspaceAdmin(req, res, next) {
|
|
if (!canAdmin(req)) {
|
|
return res.status(403).json({ error: 'Workspace admin required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireOrgAdmin(req, res, next) {
|
|
if (!isOrgAdmin(req)) {
|
|
return res.status(403).json({ error: 'Organization admin required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireOrgOwner(req, res, next) {
|
|
if (!isOrgOwner(req)) {
|
|
return res.status(403).json({ error: 'Organization owner required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requirePlatformAdmin(req, res, next) {
|
|
if (!req.user || req.user.role !== 'platform_admin') {
|
|
return res.status(403).json({ error: 'Platform admin required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Decoupled "can admin this workspace" predicate. Unlike canAdmin(req) above,
|
|
// this takes an explicit (user, workspace) pair instead of reading from req,
|
|
// so it works for routes that operate on a target workspace specified by URL
|
|
// param (rename, future settings/delete) rather than the caller's currently
|
|
// active one. Does its own DB lookups against workspace_members + organization_members.
|
|
function canAdminWorkspace(db, user, workspace) {
|
|
if (!user || !workspace) return false;
|
|
if (user.role === 'platform_admin' || user.role === 'superadmin') return true;
|
|
const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?')
|
|
.get(workspace.organization_id, user.id);
|
|
if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true;
|
|
const wm = db.prepare('SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
|
|
.get(workspace.id, user.id);
|
|
return wm && wm.role === 'workspace_admin';
|
|
}
|
|
|
|
// Read-access companion to canAdminWorkspace. Same (user, workspace) shape but
|
|
// accepts any workspace_members role (admin/editor/viewer) in addition to the
|
|
// org / platform paths. Used by GET endpoints on a URL-param target workspace
|
|
// where resolveTenancy is not on the request (e.g. /api/workspaces/:id/members).
|
|
function canAccessWorkspace(db, user, workspace) {
|
|
if (!user || !workspace) return false;
|
|
if (user.role === 'platform_admin' || user.role === 'superadmin') return true;
|
|
const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?')
|
|
.get(workspace.organization_id, user.id);
|
|
if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true;
|
|
const wm = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
|
|
.get(workspace.id, user.id);
|
|
return !!wm;
|
|
}
|
|
|
|
module.exports = {
|
|
// boolean predicates
|
|
canRead, canWrite, canAdmin, canAdminWorkspace, canAccessWorkspace, isOrgAdmin, isOrgOwner,
|
|
// express middleware
|
|
requireWorkspace,
|
|
requireWorkspaceRead,
|
|
requireWorkspaceWrite,
|
|
requireWorkspaceAdmin,
|
|
requireOrgAdmin,
|
|
requireOrgOwner,
|
|
requirePlatformAdmin,
|
|
};
|