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

Role status and cache refresh

Only roles with status_type=enable participate in menus, APIs, buttons, data scopes, or the R_SUPER check. Disabling a role clears its permission cache and refreshes every associated user's role and home-route cache; a user with no other enabled role receives 2207 USER_NO_ROLE. Re-enabling or renaming a role also refreshes associated caches and deletes keys for the old role code.

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

python
INVENTORY_MENU_CHILDREN = [
    {
        "menu_name": "Products",
        "route_name": "inventory_product",
        "route_path": "/inventory/product",
        "buttons": [
            {"button_code": "B_INVENTORY_PRODUCT_CREATE", "button_desc": "create product"},
            {"button_code": "B_INVENTORY_PRODUCT_EDIT",   "button_desc": "edit product"},
            {"button_code": "B_INVENTORY_PRODUCT_DELETE", "button_desc": "delete product"},
            {"button_code": "B_INVENTORY_PRODUCT_TRANSITION", "button_desc": "state transition"},
        ],
    },
]

await ensure_menu(menu_name="Inventory", route_name="inventory", ..., children=INVENTORY_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_INVENTORY_WAREHOUSE_CREATEInventory / warehouse / create
B_INVENTORY_PRODUCT_TRANSITIONInventory / product / state transition
B_INV_PRODUCT_DELETEInventory / product / delete

General rules:

  • One button = one action category; single delete + batch delete share one code (Inventory 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="inventory admin",
    role_code="R_INVENTORY_ADMIN",
    role_desc="Inventory specialist",
    home_route="inventory_product",
    data_scope=DataScopeType.all,
    menus=["home", "hr", "inventory_warehouse", "inventory_product", "inventory_tag"],
    buttons=["B_INVENTORY_WAREHOUSE_CREATE", "B_INVENTORY_WAREHOUSE_EDIT", ...],
    apis=[
        ("post", "/api/v1/business/inventory/products/search"),
        ("post", "/api/v1/business/inventory/products"),
        ...
    ],
)

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_INVENTORY_ADMIN': missing apis [('post', '/api/v1/business/inventory/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 scoped roles / 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_INVENTORY_PRODUCT_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 协议发布