English · 中文
The bundled template ships with a <Access> permission system on the frontend. This doc covers the backend side of that contract: what shape the API should return, how to design the database, where to enforce checks, and which token strategy fits which app.
Important
The frontend <Access> component is for UX only. It hides buttons and routes the user can't use — but a determined caller can always bypass the UI and hit your API directly. Every mutation endpoint must re-check permissions server-side. Treat the frontend gate as a polish layer, not a security boundary.
The frontend calls GET /api/user/userInfo (or whatever endpoint your useUserInfoQuery points at) and feeds the response into defineAccess(). The contract is:
interface UserInfo {
id: string;
name?: string;
email?: string;
// …other profile fields…
/** Role keys assigned to this user. Drives most access flags. */
roles?: string[];
/**
* Optional fine-grained overlay — `{ resource: actions[] }`.
* Use `'*'` to grant all actions on a resource.
*
* Most apps leave this empty and rely on `roles`. Populate it only
* when a user needs a capability outside their role.
*/
permissions?: Record<string, string[]>;
}Example response:
{
"id": "u-123",
"name": "Alice",
"email": "alice@example.com",
"roles": ["editor"],
"permissions": {
"analytics": ["export"]
}
}defineAccess then resolves named boolean flags (isAdmin, canManageContent, canExportAnalytics, …) and the UI consumes those — never the raw roles / permissions directly.
Three shapes, in increasing complexity. Pick the simplest one your product can live with — the frontend supports all three with the same UserInfo contract.
The backend only emits roles. The frontend computes everything from role membership.
SQL:
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE roles (
id TEXT PRIMARY KEY, -- e.g. 'admin', 'editor', 'viewer'
name TEXT NOT NULL,
description TEXT
);
CREATE TABLE user_roles (
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT REFERENCES roles(id) ON DELETE CASCADE,
granted_at TIMESTAMP DEFAULT NOW(),
granted_by TEXT REFERENCES users(id),
PRIMARY KEY (user_id, role_id)
);Endpoint:
// GET /api/user/userInfo
async function getUserInfo(req, res) {
const user = await db.users.findById(req.user.id);
const roles = await db.user_roles
.where({ user_id: user.id })
.join('roles', 'roles.id', 'user_roles.role_id')
.select('roles.id');
res.json({
id: user.id,
name: user.name,
email: user.email,
roles: roles.map(r => r.id), // e.g. ['admin']
// permissions: {} — leave empty for pure RBAC
});
}Frontend defineAccess:
export function defineAccess(userInfo: UserInfo | undefined) {
const roles = userInfo?.roles ?? [];
const is = (...rs: string[]) => rs.some(r => roles.includes(r));
return {
isAdmin: is('admin'),
canManageUsers: is('admin', 'hr'),
canViewBilling: is('admin', 'finance'),
};
}When to use: traditional enterprise apps with stable role definitions. Adding a new flag requires no backend change — only access.tsx updates.
The backend emits { resource: actions[] } directly. Roles become an implementation detail behind the scenes.
SQL (extends Option A):
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
resource TEXT NOT NULL, -- 'users', 'analytics', 'billing'
action TEXT NOT NULL, -- 'read', 'write', 'delete', 'export'
UNIQUE (resource, action)
);
CREATE TABLE role_permissions (
role_id TEXT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);Endpoint flattens the joins:
// GET /api/user/userInfo
async function getUserInfo(req, res) {
const user = await db.users.findById(req.user.id);
const rows = await db.query(`
SELECT DISTINCT p.resource, p.action
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE u.id = $1
`, [user.id]);
const permissions: Record<string, string[]> = {};
for (const { resource, action } of rows) {
(permissions[resource] ??= []).push(action);
}
res.json({
id: user.id,
name: user.name,
roles: [], // not exposed in pure-ACL mode
permissions, // { analytics: ['read', 'export'], … }
});
}Frontend:
export function defineAccess(userInfo: UserInfo | undefined) {
const perms = userInfo?.permissions ?? {};
const can = (r: string, a: string) =>
perms[r]?.includes(a) || perms[r]?.includes('*');
return {
canExportAnalytics: can('analytics', 'export'),
canManageBilling: can('billing', '*'),
};
}When to use: SaaS / platform products where customers configure their own roles, or where access is data-dependent.
Backend emits both: roles is the source of truth for most permissions, permissions is an overlay for one-off grants.
SQL (Options A + B combined):
CREATE TABLE user_permissions (
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
granted_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, permission_id)
);
-- A user inherits permissions from their roles AND gets extras
-- directly assigned via user_permissions.Endpoint emits both:
async function getUserInfo(req, res) {
const user = await db.users.findById(req.user.id);
const roles = await db.user_roles
.where({ user_id: user.id })
.pluck('role_id');
// Only emit the *extras* via permissions[] — anything the user gets
// through their role doesn't need to be flattened, since the
// frontend's defineAccess checks the role first.
const extras = await db.query(`
SELECT p.resource, p.action
FROM user_permissions up
JOIN permissions p ON p.id = up.permission_id
WHERE up.user_id = $1
`, [user.id]);
const permissions: Record<string, string[]> = {};
for (const { resource, action } of extras) {
(permissions[resource] ??= []).push(action);
}
res.json({
id: user.id,
name: user.name,
roles, // ['editor']
permissions, // { analytics: ['export'] } — Alice's one extra grant
});
}Frontend uses both (this is the default shape defineAccess is wired for):
export function defineAccess(userInfo) {
const roles = userInfo?.roles ?? [];
const perms = userInfo?.permissions ?? {};
const is = (...rs) => rs.some(r => roles.includes(r));
const can = (r, a) => perms[r]?.includes(a) || perms[r]?.includes('*');
return {
canExportAnalytics: is('admin', 'analyst') || can('analytics', 'export'),
// ↑ Most analysts can export. Plus, give Alice (editor) the override.
};
}When to use: most SaaS / internal apps. Roles cover 95% of decisions; the overlay handles the long tail without bloating roles.
Every mutation endpoint must independently re-check permissions. The frontend <Access> hides the button; the middleware below stops a malicious request that bypassed the UI.
import { Request, Response, NextFunction } from 'express';
interface AuthedRequest extends Request {
user: { id: string; roles: string[]; permissions: Record<string, string[]> };
}
/** Pass if the user holds ANY of the listed roles. */
export function requireRole(...allowed: string[]) {
return (req: AuthedRequest, res: Response, next: NextFunction) => {
if (!allowed.some(r => req.user.roles.includes(r))) {
return res.status(403).json({ msg: 'Forbidden' });
}
next();
};
}
/** Pass if the user has `action` on `resource` (or `'*'`). */
export function requirePermission(resource: string, action: string) {
return (req: AuthedRequest, res: Response, next: NextFunction) => {
const granted = req.user.permissions[resource] ?? [];
if (!granted.includes(action) && !granted.includes('*')) {
return res.status(403).json({ msg: 'Forbidden' });
}
next();
};
}
// Usage:
router.delete('/api/users/:id', requireRole('admin'), deleteUser);
router.put ('/api/users/:id', requireRole('admin', 'hr'), editUser);
router.get ('/api/analytics/export', requirePermission('analytics', 'export'), exportAnalytics);@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/api/users/{id}")
public void deleteUser(@PathVariable String id) { … }
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
@PutMapping("/api/users/{id}")
public User editUser(@PathVariable String id, @RequestBody UserDto body) { … }
@PreAuthorize("hasPermission('analytics', 'export')")
@GetMapping("/api/analytics/export")
public ResponseEntity<byte[]> exportAnalytics() { … }You'll need a custom PermissionEvaluator for the third pattern — Spring Security's stock one only checks role authorities.
from fastapi import Depends, HTTPException, status
from typing import Annotated
def require_role(*allowed: str):
def dep(user: Annotated[User, Depends(get_current_user)]):
if not set(allowed) & set(user.roles):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden")
return user
return dep
def require_permission(resource: str, action: str):
def dep(user: Annotated[User, Depends(get_current_user)]):
granted = user.permissions.get(resource, [])
if action not in granted and "*" not in granted:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden")
return user
return dep
@app.delete("/api/users/{id}")
def delete_user(id: str, _: Annotated[User, Depends(require_role("admin"))]):
...
@app.get("/api/analytics/export")
def export_analytics(_: Annotated[User, Depends(require_permission("analytics", "export"))]):
...func RequireRole(allowed ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userCtxKey).(*User)
for _, role := range allowed {
if slices.Contains(user.Roles, role) {
next.ServeHTTP(w, r)
return
}
}
http.Error(w, "Forbidden", http.StatusForbidden)
})
}
}
r.With(RequireRole("admin")).Delete("/api/users/{id}", deleteUser)The frontend doesn't care how authentication works — it just expects GET /api/user/userInfo to return the current user on the same origin. Pick whichever fits your backend.
- Backend issues a
Set-Cookie: session=<id>; HttpOnly; Secure; SameSite=Laxon login - Browser sends it automatically on every subsequent request
- Frontend needs
axios.create({ withCredentials: true }) - Logout =
DELETE /api/sessionclears the cookie + the server-side session row
Pros: zero token handling on the frontend, easy to revoke (delete the row). Cons: server keeps state; cross-domain needs careful CORS config.
- Backend issues a signed JWT on login (
Authorization: Bearer <jwt>orSet-Cookie) - Token payload contains user id + roles + expiry
- Backend just verifies the signature, no DB lookup per request
Pros: stateless servers scale horizontally; works across domains. Cons: hard to revoke before expiry (use short expiry + refresh token); roles in payload go stale until refresh.
// Sample JWT payload
{
"sub": "u-123",
"name": "Alice",
"roles": ["editor"],
"iat": 1700000000,
"exp": 1700003600
}Refresh token rotation:
- Access token: short-lived (5-15 min), in memory or cookie
- Refresh token: long-lived (7-30 days),
HttpOnlycookie, rotates on every use - On 401, frontend hits
/api/auth/refreshto swap the refresh token for a fresh access token + new refresh token
The frontend's 401 interceptor (in src/api/client.ts) currently navigates to /login. For refresh-token flow, swap it to call your refresh endpoint first and retry the original request only if refresh succeeds.
| Strategy | XSS-resistant? | CSRF-resistant? | When |
|---|---|---|---|
localStorage / memory |
❌ | ✅ | Internal tools, low XSS risk |
HttpOnly cookie |
✅ | ❌ (needs CSRF token) | Public products |
HttpOnly cookie + SameSite=Strict |
✅ | ✅ | Single-domain public products |
The template uses localStorage for the userStatus flag today (src/auth/useAuth.ts). For production, replace localStorage.setItem with an HTTP-only cookie set by the backend on login — it's a one-call change in useAuth.ts.
<Access> decides whether a user sees the orders page. It cannot decide which orders they see. That's a data-level concern and must live in the SQL layer.
Wrong (frontend filter):
const allOrders = await api.get('/api/orders');
const myOrders = allOrders.filter(o => o.orgId === currentOrg);
// ↑ User just downloaded every order in the database.Right (server filter):
-- Backend query auto-injects the WHERE clause
SELECT * FROM orders
WHERE org_id = :current_user_org_id
ORDER BY created_at DESC;Even better (Postgres Row-Level Security):
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON orders
USING (org_id = current_setting('app.current_org_id')::uuid);
-- Set the GUC at the start of each request:
SET LOCAL app.current_org_id = '<uuid from session>';RLS pushes the check down to the database engine, so even a bug in your service layer can't leak across tenants.
The frontend caches userInfo via TanStack Query for 60 seconds (see staleTime in src/api/queryClient.ts). When the backend changes a user's roles or permissions, you have three options:
- Wait for the next refetch — fine if changes are rare (e.g., once a day)
- Bump a version — backend issues a new JWT with a
sessionVersionclaim; mismatch forces refresh - Server-sent event — push "your permissions changed, please refetch" to the open browser tab
For option 3, the frontend invalidates:
// In the SSE handler:
queryClient.invalidateQueries({ queryKey: ['user', 'info'] });- Authenticate at the edge, authorize at the service. Auth middleware verifies the token; service-layer code re-checks permissions before mutations. Defence in depth.
- Audit every grant.
user_rolesanduser_permissionsshould havegranted_at,granted_by, optionallyexpires_atandreason. Auditors will thank you. - Don't put PII in JWTs. Tokens travel in
Authorizationheaders and end up in proxy logs. Keep payload to id + roles + expiry; fetch profile via/api/user/userInfo. - Short access-token expiry + refresh rotation. 15 minutes for the access token is a reasonable default. Refresh tokens should single-use (rotate on each refresh).
- Reject the request, don't return empty data. A user without
canViewOrderscallingGET /api/ordersshould get 403, not200 []. Empty arrays hide auth bugs. - Test the negative path. Write tests that confirm a
viewergets 403 onDELETE /api/users/:id. Positive-only tests miss permission regressions. - Use
'*'sparingly. Wildcards make audits hard. Prefer enumerated actions; reserve'*'for the literal admin role.
If you already have an auth backend, map your shape to the UserInfo contract:
| Your field | → | Template field |
|---|---|---|
current_user_id, email, name |
→ | id, email, name |
user_type, tier, groups |
→ | roles[] |
| ACL rows / capability tokens | → | permissions: Record<string, string[]> |
is_admin: true |
→ | roles: ['admin'] |
permissions: { canEdit: true } |
→ | rewrite defineAccess to consume booleans directly |
The frontend defineAccess is the only place that knows the contract. Adjusting the shape — say, replacing roles[] with groups[] — is a one-file edit.
What every working integration needs, end to end:
POST /api/user/login— verifies credentials, returns{ status: 'ok' }+ sets session cookie (or returns{ token })GET /api/user/userInfo— returns the current user's{ id, name, roles, permissions }POST /api/user/logout— clears the session cookie401on every protected endpoint — when the session/token is missing or invalid
The frontend's useAuth + useUserInfoQuery + axios 401 interceptor handle the client side. You only need to wire the three endpoints above and your permission middleware.