Compare commits

..

13 Commits

Author SHA1 Message Date
Chris
405000ded5 feat(role): add role_code across schema and APIs 2026-04-03 15:49:22 +08:00
Chris
94cec746cb chore(env): split dev/prod env files and clarify keycloak settings 2026-04-03 14:43:13 +08:00
Chris
c032020f59 chore: update backend env example 2026-04-03 06:03:04 +08:00
Chris
60b34a0817 chore: update backend env example 2026-04-03 05:57:02 +08:00
Chris
d2b6957013 dev: prefer .env.development in start script 2026-04-03 04:59:14 +08:00
Chris
7b9915e81c update 2026-04-03 04:57:22 +08:00
Chris
dc51af8c39 update 2026-04-03 04:52:17 +08:00
Chris
60608fe199 Remove redundant backend production env template 2026-04-03 04:33:47 +08:00
Chris
7c4364b52f Use single .env for local startup 2026-04-03 04:31:58 +08:00
Chris
065f1d52f0 Stop tracking local env files 2026-04-03 04:25:36 +08:00
Chris
4ae7e75a96 Ignore .venv and local build artifacts 2026-04-03 04:00:58 +08:00
Chris
d430b69888 Ignore .venv and Python cache files 2026-04-03 03:57:53 +08:00
Chris
ed7a0344e0 Remove legacy migration file and alias API routes 2026-04-03 03:54:48 +08:00
23 changed files with 176 additions and 234 deletions

19
.env
View File

@@ -1,19 +0,0 @@
# memberapi.ose.tw backend env (development)
APP_ENV=development
PORT=8000
DB_HOST=127.0.0.1
DB_PORT=54321
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
KEYCLOAK_VERIFY_TLS=true
KEYCLOAK_ISSUER=
KEYCLOAK_JWKS_URL=
KEYCLOAK_AUDIENCE=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,4 +1,4 @@
# memberapi.ose.tw backend env (local development) # memberapi.ose.tw backend env (development)
APP_ENV=development APP_ENV=development
PORT=8000 PORT=8000
@@ -8,20 +8,33 @@ DB_NAME=member.ose.tw
DB_USER=member_ose DB_USER=member_ose
DB_PASSWORD=Dmrax5bKDf DB_PASSWORD=Dmrax5bKDf
KEYCLOAK_BASE_URL=https://auth.ose.tw # Keycloak 參數說明:
# - KEYCLOAK_ISSUER 必須與 token 的 iss 完全一致(建議填公開網址)。
# - KEYCLOAK_BASE_URL 是後端對 Keycloak 的基底網址development 統一走公開入口)。
# - KEYCLOAK_JWKS_URL / KEYCLOAK_TOKEN_ENDPOINT / KEYCLOAK_USERINFO_ENDPOINT 可明確覆寫端點。
# - KEYCLOAK_AUDIENCE 可選,但建議設定以啟用 aud 驗證。
# - KEYCLOAK_CLIENT_* 給 /auth/oidc/exchange 與 /auth/refresh 使用。
# - KEYCLOAK_ADMIN_CLIENT_* 給 Keycloak Admin API 同步流程使用。
KEYCLOAK_BASE_URL=https://auth.ose.tw/
KEYCLOAK_REALM=master KEYCLOAK_REALM=master
KEYCLOAK_VERIFY_TLS=true KEYCLOAK_VERIFY_TLS=true
KEYCLOAK_ISSUER=https://auth.ose.tw/realms/master
KEYCLOAK_JWKS_URL=https://auth.ose.tw/realms/master/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=
KEYCLOAK_CLIENT_ID=member-frontend KEYCLOAK_CLIENT_ID=member-frontend
KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv
KEYCLOAK_TOKEN_ENDPOINT=https://auth.ose.tw/realms/master/protocol/openid-connect/token
KEYCLOAK_USERINFO_ENDPOINT=https://auth.ose.tw/realms/master/protocol/openid-connect/userinfo
KEYCLOAK_ADMIN_CLIENT_ID=member-backend 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=http://127.0.0.1:5173,http://localhost:5173 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
# Cache backend: memory | redis
CACHE_BACKEND=memory CACHE_BACKEND=memory
CACHE_REDIS_URL=redis://127.0.0.1:6379/0 CACHE_REDIS_URL=redis://127.0.0.1:6379/0
CACHE_PREFIX=memberapi CACHE_PREFIX=memberapi

View File

@@ -1,35 +0,0 @@
# memberapi.ose.tw backend env (development)
APP_ENV=development
PORT=8000
DB_HOST=127.0.0.1
DB_PORT=54321
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
KEYCLOAK_VERIFY_TLS=true
KEYCLOAK_ISSUER=
KEYCLOAK_JWKS_URL=
KEYCLOAK_AUDIENCE=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_TOKEN_ENDPOINT=
KEYCLOAK_USERINFO_ENDPOINT=
KEYCLOAK_ADMIN_CLIENT_ID=
KEYCLOAK_ADMIN_CLIENT_SECRET=
KEYCLOAK_ADMIN_REALM=
PUBLIC_FRONTEND_ORIGINS=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
# Cache backend: memory | redis
CACHE_BACKEND=memory
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
CACHE_PREFIX=memberapi
CACHE_DEFAULT_TTL_SECONDS=30

41
.env.production Normal file
View File

@@ -0,0 +1,41 @@
# memberapi.ose.tw backend env (development)
APP_ENV=development
PORT=8000
DB_HOST=postgresql
DB_PORT=5432
DB_NAME=member.ose.tw
DB_USER=member_ose
DB_PASSWORD=Dmrax5bKDf
# Keycloak 參數說明:
# - KEYCLOAK_ISSUER 必須與 token 的 iss 完全一致(建議填公開網址)。
# - KEYCLOAK_BASE_URL 是後端對 Keycloak 的基底網址development 統一走公開入口)。
# - KEYCLOAK_JWKS_URL / KEYCLOAK_TOKEN_ENDPOINT / KEYCLOAK_USERINFO_ENDPOINT 可明確覆寫端點。
# - KEYCLOAK_AUDIENCE 可選,但建議設定以啟用 aud 驗證。
# - KEYCLOAK_CLIENT_* 給 /auth/oidc/exchange 與 /auth/refresh 使用。
# - KEYCLOAK_ADMIN_CLIENT_* 給 Keycloak Admin API 同步流程使用。
KEYCLOAK_BASE_URL=http://auth_ose_tw:8080
KEYCLOAK_REALM=master
KEYCLOAK_VERIFY_TLS=true
KEYCLOAK_ISSUER=https://auth.ose.tw/realms/master
KEYCLOAK_JWKS_URL=http://auth_ose_tw:8080/realms/master/protocol/openid-connect/certs
KEYCLOAK_AUDIENCE=
KEYCLOAK_CLIENT_ID=member-frontend
KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv
KEYCLOAK_TOKEN_ENDPOINT=http://auth_ose_tw:8080/realms/master/protocol/openid-connect/token
KEYCLOAK_USERINFO_ENDPOINT=http://auth_ose_tw:8080/realms/master/protocol/openid-connect/userinfo
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
INTERNAL_SHARED_SECRET=CHANGE_ME
MEMBER_REQUIRED_REALM_ROLES=admin,manager
ADMIN_REQUIRED_REALM_ROLES=admin,manager
# Cache backend: memory | redis
CACHE_BACKEND=redis
CACHE_REDIS_URL=redis://cache/0
CACHE_PREFIX=memberapi
CACHE_DEFAULT_TTL_SECONDS=30

View File

@@ -1,35 +0,0 @@
# memberapi.ose.tw backend env (production)
APP_ENV=production
PORT=8000
DB_HOST=postgresql
DB_PORT=5432
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
KEYCLOAK_VERIFY_TLS=true
KEYCLOAK_ISSUER=
KEYCLOAK_JWKS_URL=
KEYCLOAK_AUDIENCE=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_TOKEN_ENDPOINT=
KEYCLOAK_USERINFO_ENDPOINT=
KEYCLOAK_ADMIN_CLIENT_ID=
KEYCLOAK_ADMIN_CLIENT_SECRET=
KEYCLOAK_ADMIN_REALM=
PUBLIC_FRONTEND_ORIGINS=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
# Cache backend: memory | redis
CACHE_BACKEND=redis
CACHE_REDIS_URL=redis://redis:6379/0
CACHE_PREFIX=memberapi
CACHE_DEFAULT_TTL_SECONDS=30

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Python cache
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
# Virtualenv
.venv/
venv/
# Build metadata
*.egg-info/
# Local env and logs
.env
*.log
# OS
.DS_Store

View File

@@ -7,7 +7,7 @@ cd backend
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -e . pip install -e .
cp .env.example .env # local development uses .env.development directly
psql "$DATABASE_URL" -f scripts/init_schema.sql psql "$DATABASE_URL" -f scripts/init_schema.sql
./scripts/start_dev.sh ./scripts/start_dev.sh
``` ```
@@ -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`
@@ -83,4 +85,11 @@ curl http://127.0.0.1:8000/healthz
- `GET /internal/members` - `GET /internal/members`
- `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/idp/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
@@ -1005,7 +1022,6 @@ def list_api_clients(
@router.post("/sync/from-provider") @router.post("/sync/from-provider")
@router.post("/sync/from-keycloak", include_in_schema=False)
def sync_catalog_from_provider(db: Session = Depends(get_db), force: bool = Query(default=True)) -> dict[str, int]: def sync_catalog_from_provider(db: Session = Depends(get_db), force: bool = Query(default=True)) -> dict[str, int]:
return sync_from_provider(db, force=force) return sync_from_provider(db, force=force)

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
], ],
@@ -103,8 +106,6 @@ def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUser
@router.post("/provider/users/ensure", response_model=ProviderEnsureUserResponse) @router.post("/provider/users/ensure", response_model=ProviderEnsureUserResponse)
@router.post("/idp/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False)
@router.post("/keycloak/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False)
def ensure_idp_user( def ensure_idp_user(
payload: ProviderEnsureUserRequest, payload: ProviderEnsureUserRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),

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;

View File

@@ -1,131 +0,0 @@
-- Rename legacy IdP column names to provider_* naming.
-- Safe to run multiple times.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'idp_group_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'provider_group_id'
) THEN
ALTER TABLE public.companies RENAME COLUMN idp_group_id TO provider_group_id;
END IF;
END $$;
-- companies.display_name -> companies.name
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'display_name'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'name'
) THEN
ALTER TABLE public.companies RENAME COLUMN display_name TO name;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'legal_name'
) THEN
ALTER TABLE public.companies DROP COLUMN legal_name;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id'
) THEN
ALTER TABLE public.systems DROP COLUMN provider_client_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name'
) THEN
ALTER TABLE public.roles DROP COLUMN provider_role_name;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_provider_role_name'
) THEN
ALTER TABLE public.roles DROP CONSTRAINT uq_roles_system_provider_role_name;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_name'
) THEN
ALTER TABLE public.roles ADD CONSTRAINT uq_roles_system_name UNIQUE (system_id, name);
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'idp_group_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'provider_group_id'
) THEN
ALTER TABLE public.sites RENAME COLUMN idp_group_id TO provider_group_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'idp_client_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id'
) THEN
ALTER TABLE public.systems RENAME COLUMN idp_client_id TO provider_client_id;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'idp_role_name'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name'
) THEN
ALTER TABLE public.roles RENAME COLUMN idp_role_name TO provider_role_name;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'idp_user_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'provider_user_id'
) THEN
ALTER TABLE public.users RENAME COLUMN idp_user_id TO provider_user_id;
END IF;
END $$;

View File

@@ -3,4 +3,11 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
source .venv/bin/activate source .venv/bin/activate
exec uvicorn app.main:app --env-file .env.development --host 127.0.0.1 --port 8000 --reload ENV_FILE=".env.development"
if [ ! -f "$ENV_FILE" ]; then
echo "missing $ENV_FILE."
exit 1
fi
echo "Loading environment from '$ENV_FILE'"
exec uvicorn app.main:app --env-file "$ENV_FILE" --host 127.0.0.1 --port 8000 --reload

View File

@@ -9,7 +9,7 @@ def test_internal_idp_ensure_requires_config() -> None:
client = TestClient(app) client = TestClient(app)
try: try:
resp = client.post( resp = client.post(
"/internal/idp/users/ensure", "/internal/provider/users/ensure",
json={ json={
"sub": "idp-sub-1", "sub": "idp-sub-1",
"email": "user@example.com", "email": "user@example.com",