Coverage for src/lexigram/admin/rbac/roles_sql.py: 89%
64 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""SQL-backed implementation of AdminRoleStoreProtocol.
3Owns all DDL and DML for the ``admin_roles`` table. The service layer
4depends only on ``AdminRoleStoreProtocol`` — never on this class directly.
5Permissions and inheritance are stored as JSON text for database-portable
6array semantics.
7"""
9from __future__ import annotations
11from typing import Any
13from lexigram.admin.rbac.protocols import AdminRoleStoreProtocol
14from lexigram.admin.sql_dialect import is_postgres, now_expr
15from lexigram.contracts.auth import RoleDefinition
16from lexigram.contracts.data import DatabaseProviderProtocol
17from lexigram.di.decorators import inject
18from lexigram.logging import get_logger
19from lexigram.serialization import dumps_str, loads
21logger = get_logger(__name__)
23_TABLE = "admin_roles"
26def _load_list(value: Any) -> list[str]:
27 """Parse a stored JSON text column into a list of strings."""
28 if value is None:
29 return []
30 if isinstance(value, list):
31 return [str(v) for v in value]
32 try:
33 parsed = loads(str(value))
34 except ValueError:
35 return []
36 return [str(v) for v in parsed] if isinstance(parsed, list) else []
39@inject
40class AdminRoleSqlStore(AdminRoleStoreProtocol):
41 """SQL store for admin roles.
43 Implements ``AdminRoleStoreProtocol``. Manages the ``admin_roles``
44 table including DDL bootstrap. Role names are the primary key; rows
45 mirror the shape ``AuthorizationService.sync_from_db`` expects from
46 its legacy ``admin_roles`` fallback table (``name``, ``description``,
47 ``permissions``, ``inherits``, plus ``is_system`` for UI protection).
48 """
50 def __init__(self, db: DatabaseProviderProtocol) -> None:
51 """Initialise with a resolved database provider.
53 Args:
54 db: Framework database provider exposing ``execute`` and
55 ``execute_query``.
56 """
57 self._db = db
58 self._initialized = False
60 async def ensure_schema(self) -> None:
61 """Create the roles table if it does not exist (idempotent)."""
62 if self._initialized:
63 return
64 if is_postgres(self._db):
65 create_sql = f"""
66 CREATE TABLE IF NOT EXISTS {_TABLE} (
67 name VARCHAR(100) PRIMARY KEY,
68 description TEXT NOT NULL DEFAULT '',
69 permissions TEXT NOT NULL DEFAULT '[]',
70 inherits TEXT NOT NULL DEFAULT '[]',
71 is_system BOOLEAN NOT NULL DEFAULT FALSE,
72 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
73 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
74 )
75 """
76 else:
77 create_sql = f"""
78 CREATE TABLE IF NOT EXISTS {_TABLE} (
79 name VARCHAR(100) PRIMARY KEY,
80 description TEXT NOT NULL DEFAULT '',
81 permissions TEXT NOT NULL DEFAULT '[]',
82 inherits TEXT NOT NULL DEFAULT '[]',
83 is_system BOOLEAN NOT NULL DEFAULT FALSE,
84 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
85 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
86 )
87 """
88 await self._db.execute(create_sql, [])
89 self._initialized = True
91 async def list_roles(self) -> list[RoleDefinition]:
92 """Return all roles ordered by name (see protocol docs)."""
93 result = await self._db.execute_query(
94 f"SELECT name, description, permissions, inherits, is_system FROM {_TABLE} ORDER BY name", # noqa: S608 — table name is module constant "admin_roles", never user input
95 [],
96 )
97 return [self._row_to_role(row) for row in self._rows(result)]
99 async def get_role(self, name: str) -> RoleDefinition | None:
100 """Look up a role by name (see protocol docs)."""
101 result = await self._db.execute_query(
102 f"SELECT name, description, permissions, inherits, is_system FROM {_TABLE} WHERE name = ?", # noqa: S608 — table name is module constant "admin_roles", never user input
103 [name],
104 )
105 rows = self._rows(result)
106 return self._row_to_role(rows[0]) if rows else None
108 async def create_role(self, role: RoleDefinition) -> None:
109 """Insert a new role (see protocol docs)."""
110 await self._db.execute(
111 f"INSERT INTO {_TABLE} (name, description, permissions, inherits, is_system) VALUES (?, ?, ?, ?, ?)", # noqa: S608 — table name is module constant "admin_roles", never user input
112 [
113 role.name,
114 role.description,
115 dumps_str(role.permissions),
116 dumps_str(role.inherits),
117 role.is_system,
118 ],
119 )
121 async def update_role(self, role: RoleDefinition) -> None:
122 """Update an existing role by name (see protocol docs)."""
123 await self._db.execute(
124 f"UPDATE {_TABLE} SET description = ?, permissions = ?, inherits = ?, is_system = ?, " # noqa: S608 — table name is module constant "admin_roles", never user input
125 f"updated_at = {now_expr(self._db)} WHERE name = ?",
126 [
127 role.description,
128 dumps_str(role.permissions),
129 dumps_str(role.inherits),
130 role.is_system,
131 role.name,
132 ],
133 )
135 async def delete_role(self, name: str) -> bool:
136 """Delete a role by name; ``True`` when a row was removed."""
137 result = await self._db.execute(
138 f"DELETE FROM {_TABLE} WHERE name = ?", # noqa: S608 — table name is module constant "admin_roles", never user input
139 [name],
140 )
141 row_count = getattr(result, "row_count", None)
142 if row_count is not None:
143 return int(row_count) > 0
144 return True
146 @staticmethod
147 def _rows(result: Any) -> list[dict[str, Any]]:
148 """Normalize execute_query results (object/.rows, list, or dict)."""
149 if hasattr(result, "rows") and result.rows:
150 return list(result.rows)
151 if isinstance(result, list):
152 return result
153 if isinstance(result, dict):
154 return [result]
155 return []
157 @staticmethod
158 def _row_to_role(row: dict[str, Any]) -> RoleDefinition:
159 """Build an RoleDefinition from a provider row."""
160 return RoleDefinition(
161 name=str(row.get("name", "")),
162 description=str(row.get("description", "")),
163 permissions=_load_list(row.get("permissions")),
164 inherits=_load_list(row.get("inherits")),
165 is_system=bool(row.get("is_system", False)),
166 )
169__all__ = ["AdminRoleSqlStore"]