From 405000ded5f40dc67e7f1c63f5759740ea223fc1 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 3 Apr 2026 15:49:22 +0800 Subject: [PATCH] feat(role): add role_code across schema and APIs --- .env.development | 4 ++-- README.md | 9 +++++++++ app/api/admin_catalog.py | 17 +++++++++++++++++ app/api/internal.py | 5 ++++- app/api/internal_catalog.py | 1 + app/api/me.py | 1 + app/models/role.py | 6 +++++- app/repositories/roles_repo.py | 6 ++++++ app/schemas/catalog.py | 5 +++++ app/schemas/internal.py | 2 ++ app/schemas/permissions.py | 1 + app/services/idp_catalog_sync.py | 4 ++++ app/services/permission_service.py | 4 +++- scripts/init_schema.sql | 5 ++++- scripts/migrate_add_role_code.sql | 27 +++++++++++++++++++++++++++ 15 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 scripts/migrate_add_role_code.sql diff --git a/.env.development b/.env.development index 66d26a0..34e8fa9 100644 --- a/.env.development +++ b/.env.development @@ -4,7 +4,7 @@ PORT=8000 DB_HOST=127.0.0.1 DB_PORT=54321 -DB_NAME=member_center +DB_NAME=member.ose.tw DB_USER=member_ose DB_PASSWORD=Dmrax5bKDf @@ -29,7 +29,7 @@ KEYCLOAK_ADMIN_CLIENT_ID=member-backend KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9 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 MEMBER_REQUIRED_REALM_ROLES=admin,manager ADMIN_REQUIRED_REALM_ROLES=admin,manager diff --git a/README.md b/README.md index 15bb581..c77a37a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ curl http://127.0.0.1:8000/healthz - `GET /admin/members/{user_sub}/roles` - `GET/POST/PATCH/DELETE /admin/api-clients` +> `roles` 現在包含 `role_code` 欄位(建議用於跨系統權限語意解析);`role_key` 保留為唯一識別鍵。 + ### Internal APIs (`X-Client-Key` + `X-API-Key`) - `GET /internal/companies` - `GET /internal/sites` @@ -84,3 +86,10 @@ curl http://127.0.0.1:8000/healthz - `POST /internal/users/upsert-by-sub` - `GET /internal/users/{user_sub}/roles` - `POST /internal/provider/users/ensure` + +## DB Migration + +- 既有 DB 升級(新增 `roles.role_code`): +```bash +psql "$DATABASE_URL" -f scripts/migrate_add_role_code.sql +``` diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index 05476ae..d064b95 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -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") +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: return CompanyItem( id=company.id, @@ -451,6 +458,7 @@ def list_roles( RoleItem( id=row.id, role_key=row.role_key, + role_code=row.role_code, system_key=system_map[row.system_id].system_key, system_name=system_map[row.system_id].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_code = _resolve_role_code(payload.role_code, payload.name) try: row = roles_repo.create( role_key=role_key, + role_code=role_code, system_id=system.id, name=payload.name, description=payload.description, @@ -496,6 +506,7 @@ def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> Ro return RoleItem( id=row.id, role_key=row.role_key, + role_code=row.role_code, system_key=system.system_key, system_name=system.name, name=row.name, @@ -527,6 +538,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends system_id = system.id target_system = system 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 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, system_id=system_id, + role_code=next_role_code, name=payload.name, description=payload.description, status=payload.status, @@ -566,6 +579,7 @@ def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends return RoleItem( id=role.id, role_key=role.role_key, + role_code=role.role_code, system_key=system.system_key, system_name=system.name, name=role.name, @@ -610,6 +624,7 @@ def list_system_roles(system_key: str, db: Session = Depends(get_db)) -> SystemR RoleItem( id=row.id, role_key=row.role_key, + role_code=row.role_code, system_key=system.system_key, system_name=system.name, name=row.name, @@ -637,6 +652,7 @@ def list_site_roles(site_key: str, db: Session = Depends(get_db)) -> SiteRolesRe SiteRoleItem( id=site_role.id, role_key=role.role_key, + role_code=role.role_code, role_name=role.name, system_key=system.system_key, 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_name=system.name, role_key=role.role_key, + role_code=role.role_code, role_name=role.name, ) for site, company, role, system in rows diff --git a/app/api/internal.py b/app/api/internal.py index 1715309..73d62c3 100644 --- a/app/api/internal.py +++ b/app/api/internal.py @@ -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) 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.name, role.role_key, + role.role_code, role.name, ) 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_name=system_name, role_key=role_key, + role_code=role_code, role_name=role_name, ) for ( @@ -94,6 +96,7 @@ def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUser system_key, system_name, role_key, + role_code, role_name, ) in rows ], diff --git a/app/api/internal_catalog.py b/app/api/internal_catalog.py index 35bab52..9bc0860 100644 --- a/app/api/internal_catalog.py +++ b/app/api/internal_catalog.py @@ -68,6 +68,7 @@ def internal_list_roles( InternalRoleItem( id=i.id, role_key=i.role_key, + role_code=i.role_code, system_key=system_map[i.system_id].system_key, system_name=system_map[i.system_id].name, name=i.name, diff --git a/app/api/me.py b/app/api/me.py index 432ddd5..c9daf10 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -77,6 +77,7 @@ def get_my_permission_snapshot( system.system_key, system.name, role.role_key, + role.role_code, role.name, ) for site, company, role, system in rows diff --git a/app/models/role.py b/app/models/role.py index 83425eb..7b49323 100644 --- a/app/models/role.py +++ b/app/models/role.py @@ -10,10 +10,14 @@ from app.db.base import Base class Role(Base): __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())) 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) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(String(1024)) diff --git a/app/repositories/roles_repo.py b/app/repositories/roles_repo.py index ff31343..7f70d6a 100644 --- a/app/repositories/roles_repo.py +++ b/app/repositories/roles_repo.py @@ -29,6 +29,7 @@ class RolesRepository: pattern = f"%{keyword}%" cond = or_( Role.role_key.ilike(pattern), + Role.role_code.ilike(pattern), Role.name.ilike(pattern), Role.description.ilike(pattern), ) @@ -48,6 +49,7 @@ class RolesRepository: self, *, role_key: str, + role_code: str, system_id: str, name: str, description: str | None, @@ -55,6 +57,7 @@ class RolesRepository: ) -> Role: item = Role( role_key=role_key, + role_code=role_code, system_id=system_id, name=name, description=description, @@ -70,12 +73,15 @@ class RolesRepository: item: Role, *, system_id: str | None = None, + role_code: str | None = None, name: str | None = None, description: str | None = None, status: str | None = None, ) -> Role: if system_id is not None: item.system_id = system_id + if role_code is not None: + item.role_code = role_code if name is not None: item.name = name if description is not None: diff --git a/app/schemas/catalog.py b/app/schemas/catalog.py index 02eeb58..0929ab4 100644 --- a/app/schemas/catalog.py +++ b/app/schemas/catalog.py @@ -74,6 +74,7 @@ class SystemItem(BaseModel): class RoleCreateRequest(BaseModel): system_key: str + role_code: str | None = None name: str description: str | None = None status: str = "active" @@ -81,6 +82,7 @@ class RoleCreateRequest(BaseModel): class RoleUpdateRequest(BaseModel): system_key: str | None = None + role_code: str | None = None name: str | None = None description: str | None = None status: str | None = None @@ -89,6 +91,7 @@ class RoleUpdateRequest(BaseModel): class RoleItem(BaseModel): id: str role_key: str + role_code: str system_key: str system_name: str name: str @@ -138,6 +141,7 @@ class SiteRoleAssignRequest(BaseModel): class SiteRoleItem(BaseModel): id: str role_key: str + role_code: str role_name: str system_key: str system_name: str @@ -163,6 +167,7 @@ class UserEffectiveRoleItem(BaseModel): system_key: str system_name: str role_key: str + role_code: str role_name: str diff --git a/app/schemas/internal.py b/app/schemas/internal.py index f88a63a..6fca351 100644 --- a/app/schemas/internal.py +++ b/app/schemas/internal.py @@ -18,6 +18,7 @@ class InternalSystemListResponse(BaseModel): class InternalRoleItem(BaseModel): id: str role_key: str + role_code: str system_key: str system_name: str name: str @@ -99,6 +100,7 @@ class InternalUserRoleItem(BaseModel): system_key: str system_name: str role_key: str + role_code: str role_name: str diff --git a/app/schemas/permissions.py b/app/schemas/permissions.py index 5af6bac..160cedd 100644 --- a/app/schemas/permissions.py +++ b/app/schemas/permissions.py @@ -9,6 +9,7 @@ class RoleSnapshotItem(BaseModel): system_key: str system_name: str role_key: str + role_code: str role_name: str diff --git a/app/services/idp_catalog_sync.py b/app/services/idp_catalog_sync.py index f2c4aa8..76c20df 100644 --- a/app/services/idp_catalog_sync.py +++ b/app/services/idp_catalog_sync.py @@ -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) roles_repo.create( role_key=role_key, + role_code=role_name, system_id=system.id, name=role_name, description=role_desc, @@ -259,6 +260,7 @@ def sync_from_provider(db: Session, *, force: bool = False) -> dict[str, int]: else: roles_repo.update( role, + role_code=role.role_code or role_name, name=role_name, description=role_desc, 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) roles_repo.create( role_key=role_key, + role_code=role_name, system_id=system.id, name=role_name, description=role_desc, @@ -382,6 +385,7 @@ def sync_systems_from_provider(db: Session, *, force: bool = False) -> dict[str, else: roles_repo.update( role, + role_code=role.role_code or role_name, name=role_name, description=role_desc, status=role_status, diff --git a/app/services/permission_service.py b/app/services/permission_service.py index f00d377..5ba9b0d 100644 --- a/app/services/permission_service.py +++ b/app/services/permission_service.py @@ -3,7 +3,7 @@ from app.schemas.permissions import RoleSnapshotItem, RoleSnapshotResponse class PermissionService: @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( user_sub=user_sub, roles=[ @@ -15,6 +15,7 @@ class PermissionService: system_key=system_key, system_name=system_name, role_key=role_key, + role_code=role_code, role_name=role_name, ) for ( @@ -25,6 +26,7 @@ class PermissionService: system_key, system_name, role_key, + role_code, role_name, ) in rows ], diff --git a/scripts/init_schema.sql b/scripts/init_schema.sql index eb335ed..739a472 100644 --- a/scripts/init_schema.sql +++ b/scripts/init_schema.sql @@ -68,13 +68,15 @@ CREATE TABLE systems ( CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role_key TEXT NOT NULL UNIQUE, + role_code TEXT NOT NULL, system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, status VARCHAR(16) NOT NULL DEFAULT 'active', created_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 ( @@ -126,6 +128,7 @@ CREATE INDEX idx_users_username ON users(username); CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_sites_company_id ON sites(company_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_role_id ON site_roles(role_id); CREATE INDEX idx_user_sites_user_id ON user_sites(user_id); diff --git a/scripts/migrate_add_role_code.sql b/scripts/migrate_add_role_code.sql new file mode 100644 index 0000000..8035342 --- /dev/null +++ b/scripts/migrate_add_role_code.sql @@ -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;