feat(role): add role_code across schema and APIs

This commit is contained in:
Chris
2026-04-03 15:49:22 +08:00
parent 94cec746cb
commit 405000ded5
15 changed files with 91 additions and 6 deletions

View File

@@ -4,7 +4,7 @@ PORT=8000
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=54321 DB_PORT=54321
DB_NAME=member_center DB_NAME=member.ose.tw
DB_USER=member_ose DB_USER=member_ose
DB_PASSWORD=Dmrax5bKDf DB_PASSWORD=Dmrax5bKDf
@@ -29,7 +29,7 @@ KEYCLOAK_ADMIN_CLIENT_ID=member-backend
KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9 KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9
KEYCLOAK_ADMIN_REALM=master KEYCLOAK_ADMIN_REALM=master
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME INTERNAL_SHARED_SECRET=CHANGE_ME
MEMBER_REQUIRED_REALM_ROLES=admin,manager MEMBER_REQUIRED_REALM_ROLES=admin,manager
ADMIN_REQUIRED_REALM_ROLES=admin,manager ADMIN_REQUIRED_REALM_ROLES=admin,manager

View File

@@ -75,6 +75,8 @@ curl http://127.0.0.1:8000/healthz
- `GET /admin/members/{user_sub}/roles` - `GET /admin/members/{user_sub}/roles`
- `GET/POST/PATCH/DELETE /admin/api-clients` - `GET/POST/PATCH/DELETE /admin/api-clients`
> `roles` 現在包含 `role_code` 欄位(建議用於跨系統權限語意解析);`role_key` 保留為唯一識別鍵。
### Internal APIs (`X-Client-Key` + `X-API-Key`) ### Internal APIs (`X-Client-Key` + `X-API-Key`)
- `GET /internal/companies` - `GET /internal/companies`
- `GET /internal/sites` - `GET /internal/sites`
@@ -84,3 +86,10 @@ curl http://127.0.0.1:8000/healthz
- `POST /internal/users/upsert-by-sub` - `POST /internal/users/upsert-by-sub`
- `GET /internal/users/{user_sub}/roles` - `GET /internal/users/{user_sub}/roles`
- `POST /internal/provider/users/ensure` - `POST /internal/provider/users/ensure`
## DB Migration
- 既有 DB 升級(新增 `roles.role_code`
```bash
psql "$DATABASE_URL" -f scripts/migrate_add_role_code.sql
```

View File

@@ -71,6 +71,13 @@ def _generate_unique_key(prefix: str, exists_check) -> str:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_generate_{prefix.lower()}_key") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_generate_{prefix.lower()}_key")
def _resolve_role_code(value: str | None, fallback_name: str) -> str:
candidate = (value or "").strip()
if candidate:
return candidate
return fallback_name.strip()
def _company_item(company) -> CompanyItem: def _company_item(company) -> CompanyItem:
return CompanyItem( return CompanyItem(
id=company.id, id=company.id,
@@ -451,6 +458,7 @@ def list_roles(
RoleItem( RoleItem(
id=row.id, id=row.id,
role_key=row.role_key, role_key=row.role_key,
role_code=row.role_code,
system_key=system_map[row.system_id].system_key, system_key=system_map[row.system_id].system_key,
system_name=system_map[row.system_id].name, system_name=system_map[row.system_id].name,
name=row.name, name=row.name,
@@ -481,9 +489,11 @@ def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> Ro
) )
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
role_code = _resolve_role_code(payload.role_code, payload.name)
try: try:
row = roles_repo.create( row = roles_repo.create(
role_key=role_key, role_key=role_key,
role_code=role_code,
system_id=system.id, system_id=system.id,
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
@@ -496,6 +506,7 @@ def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> Ro
return RoleItem( return RoleItem(
id=row.id, id=row.id,
role_key=row.role_key, role_key=row.role_key,
role_code=row.role_code,
system_key=system.system_key, system_key=system.system_key,
system_name=system.name, system_name=system.name,
name=row.name, name=row.name,
@@ -527,6 +538,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends
system_id = system.id system_id = system.id
target_system = system target_system = system
next_provider_role_name = payload.name if payload.name is not None else role.name next_provider_role_name = payload.name if payload.name is not None else role.name
next_role_code = _resolve_role_code(payload.role_code, next_provider_role_name)
next_description = payload.description if payload.description is not None else role.description next_description = payload.description if payload.description is not None else role.description
if target_system.id != old_system.id: if target_system.id != old_system.id:
@@ -551,6 +563,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends
role = roles_repo.update( role = roles_repo.update(
role, role,
system_id=system_id, system_id=system_id,
role_code=next_role_code,
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
status=payload.status, status=payload.status,
@@ -566,6 +579,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends
return RoleItem( return RoleItem(
id=role.id, id=role.id,
role_key=role.role_key, role_key=role.role_key,
role_code=role.role_code,
system_key=system.system_key, system_key=system.system_key,
system_name=system.name, system_name=system.name,
name=role.name, name=role.name,
@@ -610,6 +624,7 @@ def list_system_roles(system_key: str, db: Session = Depends(get_db)) -> SystemR
RoleItem( RoleItem(
id=row.id, id=row.id,
role_key=row.role_key, role_key=row.role_key,
role_code=row.role_code,
system_key=system.system_key, system_key=system.system_key,
system_name=system.name, system_name=system.name,
name=row.name, name=row.name,
@@ -637,6 +652,7 @@ def list_site_roles(site_key: str, db: Session = Depends(get_db)) -> SiteRolesRe
SiteRoleItem( SiteRoleItem(
id=site_role.id, id=site_role.id,
role_key=role.role_key, role_key=role.role_key,
role_code=role.role_code,
role_name=role.name, role_name=role.name,
system_key=system.system_key, system_key=system.system_key,
system_name=system.name, system_name=system.name,
@@ -972,6 +988,7 @@ def list_member_effective_roles(user_sub: str, db: Session = Depends(get_db)) ->
system_key=system.system_key, system_key=system.system_key,
system_name=system.name, system_name=system.name,
role_key=role.role_key, role_key=role.role_key,
role_code=role.role_code,
role_name=role.name, role_name=role.name,
) )
for site, company, role, system in rows for site, company, role, system in rows

View File

@@ -41,7 +41,7 @@ def upsert_user_by_sub(
) )
def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str]]: def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str, str]]:
users_repo = UsersRepository(db) users_repo = UsersRepository(db)
user_sites_repo = UserSitesRepository(db) user_sites_repo = UserSitesRepository(db)
@@ -59,6 +59,7 @@ def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, st
system.system_key, system.system_key,
system.name, system.name,
role.role_key, role.role_key,
role.role_code,
role.name, role.name,
) )
for site, company, role, system in rows for site, company, role, system in rows
@@ -84,6 +85,7 @@ def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUser
system_key=system_key, system_key=system_key,
system_name=system_name, system_name=system_name,
role_key=role_key, role_key=role_key,
role_code=role_code,
role_name=role_name, role_name=role_name,
) )
for ( for (
@@ -94,6 +96,7 @@ def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUser
system_key, system_key,
system_name, system_name,
role_key, role_key,
role_code,
role_name, role_name,
) in rows ) in rows
], ],

View File

@@ -68,6 +68,7 @@ def internal_list_roles(
InternalRoleItem( InternalRoleItem(
id=i.id, id=i.id,
role_key=i.role_key, role_key=i.role_key,
role_code=i.role_code,
system_key=system_map[i.system_id].system_key, system_key=system_map[i.system_id].system_key,
system_name=system_map[i.system_id].name, system_name=system_map[i.system_id].name,
name=i.name, name=i.name,

View File

@@ -77,6 +77,7 @@ def get_my_permission_snapshot(
system.system_key, system.system_key,
system.name, system.name,
role.role_key, role.role_key,
role.role_code,
role.name, role.name,
) )
for site, company, role, system in rows for site, company, role, system in rows

View File

@@ -10,10 +10,14 @@ from app.db.base import Base
class Role(Base): class Role(Base):
__tablename__ = "roles" __tablename__ = "roles"
__table_args__ = (UniqueConstraint("system_id", "name", name="uq_roles_system_name"),) __table_args__ = (
UniqueConstraint("system_id", "name", name="uq_roles_system_name"),
UniqueConstraint("system_id", "role_code", name="uq_roles_system_role_code"),
)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
role_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) role_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
role_code: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
system_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("systems.id", ondelete="CASCADE"), nullable=False) system_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("systems.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(String(1024)) description: Mapped[str | None] = mapped_column(String(1024))

View File

@@ -29,6 +29,7 @@ class RolesRepository:
pattern = f"%{keyword}%" pattern = f"%{keyword}%"
cond = or_( cond = or_(
Role.role_key.ilike(pattern), Role.role_key.ilike(pattern),
Role.role_code.ilike(pattern),
Role.name.ilike(pattern), Role.name.ilike(pattern),
Role.description.ilike(pattern), Role.description.ilike(pattern),
) )
@@ -48,6 +49,7 @@ class RolesRepository:
self, self,
*, *,
role_key: str, role_key: str,
role_code: str,
system_id: str, system_id: str,
name: str, name: str,
description: str | None, description: str | None,
@@ -55,6 +57,7 @@ class RolesRepository:
) -> Role: ) -> Role:
item = Role( item = Role(
role_key=role_key, role_key=role_key,
role_code=role_code,
system_id=system_id, system_id=system_id,
name=name, name=name,
description=description, description=description,
@@ -70,12 +73,15 @@ class RolesRepository:
item: Role, item: Role,
*, *,
system_id: str | None = None, system_id: str | None = None,
role_code: str | None = None,
name: str | None = None, name: str | None = None,
description: str | None = None, description: str | None = None,
status: str | None = None, status: str | None = None,
) -> Role: ) -> Role:
if system_id is not None: if system_id is not None:
item.system_id = system_id item.system_id = system_id
if role_code is not None:
item.role_code = role_code
if name is not None: if name is not None:
item.name = name item.name = name
if description is not None: if description is not None:

View File

@@ -74,6 +74,7 @@ class SystemItem(BaseModel):
class RoleCreateRequest(BaseModel): class RoleCreateRequest(BaseModel):
system_key: str system_key: str
role_code: str | None = None
name: str name: str
description: str | None = None description: str | None = None
status: str = "active" status: str = "active"
@@ -81,6 +82,7 @@ class RoleCreateRequest(BaseModel):
class RoleUpdateRequest(BaseModel): class RoleUpdateRequest(BaseModel):
system_key: str | None = None system_key: str | None = None
role_code: str | None = None
name: str | None = None name: str | None = None
description: str | None = None description: str | None = None
status: str | None = None status: str | None = None
@@ -89,6 +91,7 @@ class RoleUpdateRequest(BaseModel):
class RoleItem(BaseModel): class RoleItem(BaseModel):
id: str id: str
role_key: str role_key: str
role_code: str
system_key: str system_key: str
system_name: str system_name: str
name: str name: str
@@ -138,6 +141,7 @@ class SiteRoleAssignRequest(BaseModel):
class SiteRoleItem(BaseModel): class SiteRoleItem(BaseModel):
id: str id: str
role_key: str role_key: str
role_code: str
role_name: str role_name: str
system_key: str system_key: str
system_name: str system_name: str
@@ -163,6 +167,7 @@ class UserEffectiveRoleItem(BaseModel):
system_key: str system_key: str
system_name: str system_name: str
role_key: str role_key: str
role_code: str
role_name: str role_name: str

View File

@@ -18,6 +18,7 @@ class InternalSystemListResponse(BaseModel):
class InternalRoleItem(BaseModel): class InternalRoleItem(BaseModel):
id: str id: str
role_key: str role_key: str
role_code: str
system_key: str system_key: str
system_name: str system_name: str
name: str name: str
@@ -99,6 +100,7 @@ class InternalUserRoleItem(BaseModel):
system_key: str system_key: str
system_name: str system_name: str
role_key: str role_key: str
role_code: str
role_name: str role_name: str

View File

@@ -9,6 +9,7 @@ class RoleSnapshotItem(BaseModel):
system_key: str system_key: str
system_name: str system_name: str
role_key: str role_key: str
role_code: str
role_name: str role_name: str

View File

@@ -250,6 +250,7 @@ def sync_from_provider(db: Session, *, force: bool = False) -> dict[str, int]:
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
roles_repo.create( roles_repo.create(
role_key=role_key, role_key=role_key,
role_code=role_name,
system_id=system.id, system_id=system.id,
name=role_name, name=role_name,
description=role_desc, description=role_desc,
@@ -259,6 +260,7 @@ def sync_from_provider(db: Session, *, force: bool = False) -> dict[str, int]:
else: else:
roles_repo.update( roles_repo.update(
role, role,
role_code=role.role_code or role_name,
name=role_name, name=role_name,
description=role_desc, description=role_desc,
status=role_status, status=role_status,
@@ -373,6 +375,7 @@ def sync_systems_from_provider(db: Session, *, force: bool = False) -> dict[str,
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
roles_repo.create( roles_repo.create(
role_key=role_key, role_key=role_key,
role_code=role_name,
system_id=system.id, system_id=system.id,
name=role_name, name=role_name,
description=role_desc, description=role_desc,
@@ -382,6 +385,7 @@ def sync_systems_from_provider(db: Session, *, force: bool = False) -> dict[str,
else: else:
roles_repo.update( roles_repo.update(
role, role,
role_code=role.role_code or role_name,
name=role_name, name=role_name,
description=role_desc, description=role_desc,
status=role_status, status=role_status,

View File

@@ -3,7 +3,7 @@ from app.schemas.permissions import RoleSnapshotItem, RoleSnapshotResponse
class PermissionService: class PermissionService:
@staticmethod @staticmethod
def build_role_snapshot(user_sub: str, rows: list[tuple[str, str, str, str, str, str, str, str]]) -> RoleSnapshotResponse: def build_role_snapshot(user_sub: str, rows: list[tuple[str, str, str, str, str, str, str, str, str]]) -> RoleSnapshotResponse:
return RoleSnapshotResponse( return RoleSnapshotResponse(
user_sub=user_sub, user_sub=user_sub,
roles=[ roles=[
@@ -15,6 +15,7 @@ class PermissionService:
system_key=system_key, system_key=system_key,
system_name=system_name, system_name=system_name,
role_key=role_key, role_key=role_key,
role_code=role_code,
role_name=role_name, role_name=role_name,
) )
for ( for (
@@ -25,6 +26,7 @@ class PermissionService:
system_key, system_key,
system_name, system_name,
role_key, role_key,
role_code,
role_name, role_name,
) in rows ) in rows
], ],

View File

@@ -68,13 +68,15 @@ CREATE TABLE systems (
CREATE TABLE roles ( CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_key TEXT NOT NULL UNIQUE, role_key TEXT NOT NULL UNIQUE,
role_code TEXT NOT NULL,
system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE, system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT, description TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'active', status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_roles_system_name UNIQUE (system_id, name) CONSTRAINT uq_roles_system_name UNIQUE (system_id, name),
CONSTRAINT uq_roles_system_role_code UNIQUE (system_id, role_code)
); );
CREATE TABLE site_roles ( CREATE TABLE site_roles (
@@ -126,6 +128,7 @@ CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_sites_company_id ON sites(company_id); CREATE INDEX idx_sites_company_id ON sites(company_id);
CREATE INDEX idx_roles_system_id ON roles(system_id); CREATE INDEX idx_roles_system_id ON roles(system_id);
CREATE INDEX idx_roles_role_code ON roles(role_code);
CREATE INDEX idx_site_roles_site_id ON site_roles(site_id); CREATE INDEX idx_site_roles_site_id ON site_roles(site_id);
CREATE INDEX idx_site_roles_role_id ON site_roles(role_id); CREATE INDEX idx_site_roles_role_id ON site_roles(role_id);
CREATE INDEX idx_user_sites_user_id ON user_sites(user_id); CREATE INDEX idx_user_sites_user_id ON user_sites(user_id);

View File

@@ -0,0 +1,27 @@
BEGIN;
ALTER TABLE roles
ADD COLUMN IF NOT EXISTS role_code TEXT;
UPDATE roles
SET role_code = name
WHERE role_code IS NULL OR btrim(role_code) = '';
ALTER TABLE roles
ALTER COLUMN role_code SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'uq_roles_system_role_code'
) THEN
ALTER TABLE roles
ADD CONSTRAINT uq_roles_system_role_code UNIQUE (system_id, role_code);
END IF;
END$$;
CREATE INDEX IF NOT EXISTS idx_roles_role_code ON roles(role_code);
COMMIT;