Skip to content

RBAC (menus / APIs / buttons)

Classic RBAC: User ↔ Role ↔ {Menu / Button / API}; R_SUPER bypasses every check. This page covers the data model and runtime; JWT / invalidation is in Auth; row-level scope is in Data scope.

Relationships

User ──M2M─→ Role ──M2M─→ Menu      (frontend-visible routes)
                  ──M2M─→ Button    (in-page actionable buttons)
                  ──M2M─→ Api       (callable backend endpoints)
                  ──FK──→ Menu      (default landing page)
                  field   data_scope

Source models: app/system/models/admin.py.

Three permission dimensions

DimensionControlsDeclared byChecked when
MenuFrontend-visible route treeinit_data.py's ensure_menuGET /api/v1/route/user-routes filters by role
APICallable backend endpointrefresh_api_list auto-reconciles from FastAPI routesDependPermission per request
ButtonIn-page actioninit_data.py's ensure_menu(buttons=...)require_buttons (backend); hasAuth(...) (frontend)

An action typically needs both "button + API". Hiding only the button isn't safe; only blocking the API hurts UX.

Super admin

  • Code R_SUPER (app.core.constants.SUPER_ADMIN_ROLE)
  • DependPermission / require_buttons / require_roles short-circuit on R_SUPER
  • _ensure_super_role() re-attaches every non-constant menu + every button to this role on every startup

Each module declares menus (with their buttons) in its init_data.py; ensure_menu upserts into Menu / Button:

python
HR_MENU_CHILDREN = [
    {
        "menu_name": "Employees",
        "route_name": "hr_employee",
        "route_path": "/hr/employee",
        "buttons": [
            {"button_code": "B_HR_EMP_CREATE", "button_desc": "create employee"},
            {"button_code": "B_HR_EMP_EDIT",   "button_desc": "edit employee"},
            {"button_code": "B_HR_EMP_DELETE", "button_desc": "delete employee"},
            {"button_code": "B_HR_EMP_TRANSITION", "button_desc": "state transition"},
        ],
    },
]

await ensure_menu(menu_name="HR", route_name="hr", ..., children=HR_MENU_CHILDREN)

To "delete entries that are no longer in the seed", enable reconcile_menu_subtree(root_route="hr", ...) — the subtree enters IaC mode. See Init data.

Button naming convention

B_<MODULE>_<RESOURCE>_<ACTION>
ExampleMeaning
B_HR_DEPT_CREATEHR / department / create
B_HR_EMP_TRANSITIONHR / employee / state transition
B_INV_PRODUCT_DELETEInventory / product / delete

General rules:

  • One button = one action category; single delete + batch delete share one code (HR does this)
  • "Read list" doesn't need a button — menu visibility + API authorization are enough
  • Truly cross-module buttons (rare) live in the system layer

API: auto-reconciled

refresh_api_list() (app/system/api/utils.py) on every startup:

  1. Lists all APIRoutes' (method, path)
  2. Set-diffs against Api rows where is_system=True
  3. Extras → DELETE + Radar warning ("API deleted")
  4. Missing → INSERT
  5. Existing → UPDATE summary / tags

Developers never maintain the Api table by hand — adding / removing / renaming routes auto-syncs.

Api.status_type=disable lets an admin temporarily disable an endpoint via Web UI; hits return 2200 API_DISABLED.

Role seed declaration

python
from app.core.data_scope import DataScopeType
from app.system.services import ensure_role

await ensure_role(
    role_name="HR admin",
    role_code="R_HR_ADMIN",
    role_desc="HR specialist",
    home_route="hr_employee",
    data_scope=DataScopeType.all,
    menus=["home", "hr", "hr_department", "hr_employee", "hr_tag"],
    buttons=["B_HR_DEPT_CREATE", "B_HR_DEPT_EDIT", ...],
    apis=[
        ("post", "/api/v1/business/hr/employees/search"),
        ("post", "/api/v1/business/hr/employees"),
        ...
    ],
)

ensure_role does clear-and-readd for menus / buttons / apis (None=skip, []=clear, [...] = replace).

Drift warnings

When a declared route_name / button_code / (method, path) doesn't exist in the DB:

ensure_role 'R_HR_ADMIN': missing apis [('post', '/api/v1/business/hr/old')] (route signature changed?)

Fix on sight — the seed is out of sync with the code. See Init data / drift.

data_scope must be explicit

Omitting data_scope on ensure_role(...) keeps the model default all — wrong for department managers / regular users. Always set it explicitly in business role seeds. See Data scope.

Backend dependencies

python
from app.utils import DependPermission, require_buttons, require_roles
DependencyUseFailure code
DependPermissionMount on a router group (include_router(..., dependencies=[DependPermission]))2200 / 2201
require_buttons("B_X", ...)any one2203
require_buttons(..., require_all=True)all required2202
require_roles("R_X", ...)any one2205
require_roles(..., require_all=True)all required2204

R_SUPER always passes. See Auth / dependencies.

Frontend button gating

Button codes are delivered via GET /api/v1/auth/user-info (sourced from CTX_BUTTON_CODES; R_SUPER users get all codes). The frontend uses hasAuth('B_HR_EMP_CREATE') to decide whether to render a button — see Frontend / Hooks / useTable / Pair with permission buttons.

Cache

Redis KeyContent
role:{code}:menusmenu IDs
role:{code}:apis[{method, path, status}]
role:{code}:buttonsbutton codes
role:{code}:data_scopedata scope
user:{uid}:rolesrole codes
user:{uid}:role_homeroute name of home page

Write timing:

  • Startup refresh_all_cache(redis) loads everything
  • After role / user / menu CUD, the business calls load_role_permissions(redis, role_code=...) / load_user_roles(redis, user_id=...) to update incrementally

DependAuth / DependPermission read directly from Redis; on Redis failure they fall back to DB (with WARNING). See Cache.

See also

基于 MIT 协议发布