Skip to content

Latest commit

 

History

History
514 lines (395 loc) · 17.5 KB

File metadata and controls

514 lines (395 loc) · 17.5 KB

Backend authorization integration

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.

What the frontend expects

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.


Pick a permission model

Three shapes, in increasing complexity. Pick the simplest one your product can live with — the frontend supports all three with the same UserInfo contract.

Option A — Pure RBAC (recommended for 95% of apps)

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.

Option B — Permission overlay (when roles aren't enough)

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.

Option C — Hybrid (what this template assumes)

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.


Authorization middleware

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.

Node.js / Express

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);

Spring Boot (Java)

@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.

Python / FastAPI

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"))]):
    ...

Go (chi / net/http)

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)

Token / session strategy

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.

Session cookie (simplest)

  • Backend issues a Set-Cookie: session=<id>; HttpOnly; Secure; SameSite=Lax on login
  • Browser sends it automatically on every subsequent request
  • Frontend needs axios.create({ withCredentials: true })
  • Logout = DELETE /api/session clears 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.

JWT (stateless)

  • Backend issues a signed JWT on login (Authorization: Bearer <jwt> or Set-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), HttpOnly cookie, rotates on every use
  • On 401, frontend hits /api/auth/refresh to 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.

Where to put the access token

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.


Data-level filtering ("I only see my org's data")

<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.


Permission caching & invalidation

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:

  1. Wait for the next refetch — fine if changes are rare (e.g., once a day)
  2. Bump a version — backend issues a new JWT with a sessionVersion claim; mismatch forces refresh
  3. 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'] });

Best practices

  • 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_roles and user_permissions should have granted_at, granted_by, optionally expires_at and reason. Auditors will thank you.
  • Don't put PII in JWTs. Tokens travel in Authorization headers 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 canViewOrders calling GET /api/orders should get 403, not 200 []. Empty arrays hide auth bugs.
  • Test the negative path. Write tests that confirm a viewer gets 403 on DELETE /api/users/:id. Positive-only tests miss permission regressions.
  • Use '*' sparingly. Wildcards make audits hard. Prefer enumerated actions; reserve '*' for the literal admin role.

Migrating from your existing auth

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.


Reference: bare-minimum login flow

What every working integration needs, end to end:

  1. POST /api/user/login — verifies credentials, returns { status: 'ok' } + sets session cookie (or returns { token })
  2. GET /api/user/userInfo — returns the current user's { id, name, roles, permissions }
  3. POST /api/user/logout — clears the session cookie
  4. 401 on 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.