umacs 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. umacs-0.7.2/PKG-INFO +54 -0
  2. umacs-0.7.2/README.md +45 -0
  3. umacs-0.7.2/app/__init__.py +1 -0
  4. umacs-0.7.2/app/actions/__init__.py +0 -0
  5. umacs-0.7.2/app/actions/auth.py +28 -0
  6. umacs-0.7.2/app/actions/groups.py +85 -0
  7. umacs-0.7.2/app/actions/policies.py +27 -0
  8. umacs-0.7.2/app/actions/resources.py +36 -0
  9. umacs-0.7.2/app/actions/roles.py +27 -0
  10. umacs-0.7.2/app/actions/users.py +111 -0
  11. umacs-0.7.2/app/actions/workspaces.py +37 -0
  12. umacs-0.7.2/app/api/__init__.py +14 -0
  13. umacs-0.7.2/app/api/auth.py +40 -0
  14. umacs-0.7.2/app/api/enforce.py +21 -0
  15. umacs-0.7.2/app/api/groups.py +60 -0
  16. umacs-0.7.2/app/api/models_route.py +47 -0
  17. umacs-0.7.2/app/api/policies.py +36 -0
  18. umacs-0.7.2/app/api/resources.py +35 -0
  19. umacs-0.7.2/app/api/roles.py +41 -0
  20. umacs-0.7.2/app/api/users.py +50 -0
  21. umacs-0.7.2/app/api/workspaces.py +50 -0
  22. umacs-0.7.2/app/app.py +89 -0
  23. umacs-0.7.2/app/authz.py +27 -0
  24. umacs-0.7.2/app/cli.py +21 -0
  25. umacs-0.7.2/app/config.py +38 -0
  26. umacs-0.7.2/app/exceptions/__init__.py +0 -0
  27. umacs-0.7.2/app/rbac_model.conf +26 -0
  28. umacs-0.7.2/app/schemas/__init__.py +34 -0
  29. umacs-0.7.2/app/schemas/auth.py +32 -0
  30. umacs-0.7.2/app/schemas/decision.py +14 -0
  31. umacs-0.7.2/app/schemas/enforce.py +11 -0
  32. umacs-0.7.2/app/schemas/group.py +27 -0
  33. umacs-0.7.2/app/schemas/policy.py +62 -0
  34. umacs-0.7.2/app/schemas/resource.py +19 -0
  35. umacs-0.7.2/app/schemas/role.py +11 -0
  36. umacs-0.7.2/app/schemas/user.py +33 -0
  37. umacs-0.7.2/app/schemas/workspace.py +23 -0
  38. umacs-0.7.2/app/tables.py +180 -0
  39. umacs-0.7.2/app/utils/__init__.py +0 -0
  40. umacs-0.7.2/app/utils/auth/__init__.py +0 -0
  41. umacs-0.7.2/app/utils/auth/deps.py +33 -0
  42. umacs-0.7.2/app/utils/auth/middleware.py +136 -0
  43. umacs-0.7.2/app/utils/auth/password.py +52 -0
  44. umacs-0.7.2/app/utils/casbin_middleware.py +66 -0
  45. umacs-0.7.2/app/utils/db.py +26 -0
  46. umacs-0.7.2/app/utils/logging/__init__.py +4 -0
  47. umacs-0.7.2/app/utils/logging/audit.py +78 -0
  48. umacs-0.7.2/app/utils/logging/formatter.py +48 -0
  49. umacs-0.7.2/app/utils/sso/__init__.py +0 -0
  50. umacs-0.7.2/app/utils/sso/claims.py +63 -0
  51. umacs-0.7.2/app/utils/sso/oidc.py +152 -0
  52. umacs-0.7.2/app/utils/sso/scopes.py +36 -0
  53. umacs-0.7.2/pyproject.toml +108 -0
  54. umacs-0.7.2/setup.cfg +4 -0
  55. umacs-0.7.2/umacs/client.py +214 -0
  56. umacs-0.7.2/umacs.egg-info/PKG-INFO +54 -0
  57. umacs-0.7.2/umacs.egg-info/SOURCES.txt +58 -0
  58. umacs-0.7.2/umacs.egg-info/dependency_links.txt +1 -0
  59. umacs-0.7.2/umacs.egg-info/requires.txt +2 -0
  60. umacs-0.7.2/umacs.egg-info/top_level.txt +2 -0
umacs-0.7.2/PKG-INFO ADDED
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: umacs
3
+ Version: 0.7.2
4
+ Summary: UMACS is a unified management and access control service.
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pydantic>=2.11.7
8
+ Requires-Dist: httpx>=0.28.1
9
+
10
+ # UMACS User Management and Access Control Service
11
+
12
+ [![codecov](https://codecov.io/github/csed-ucm/umacs/graph/badge.svg?token=NDVM21XN8W)](https://codecov.io/github/csed-ucm/umacs)
13
+
14
+ UMACS (User Management and Access Control Service) is a lightweight, reusable Authorization service.
15
+
16
+ ## Features
17
+
18
+ - [x] **Policy Engine** based on Casbin for flexible access control, supporting RBAC and ABAC.
19
+ - [x] **Logging** using SQL tables for efficient storage and auditing of decision logs.
20
+ - [x] **JWKS discovery** + **caching** for validating tokens from configured IdPs and rejecting invalid tokens.
21
+ - [x] **API** for managing users, roles, and permissions.
22
+
23
+ ## Core Dependencies
24
+
25
+ The service is built using:
26
+
27
+ - FastAPI + Uvicorn
28
+ - Authlib (OIDC JWT verification)
29
+ - Casbin (RBAC + optional ABAC)
30
+ - SQLModel + AsyncSQLAlchemy (Policies, Users, Resources, Decision Logs)
31
+ - Alembic (Database Migrations)
32
+
33
+ ## Quickstart
34
+
35
+ ### Prerequisites
36
+
37
+ #### Environment
38
+
39
+ Create a `.env` file based on `.env.example`.
40
+
41
+ ### Database Setup
42
+
43
+ Run migrations to set up the SQL database:
44
+
45
+ ```sh
46
+ uv run alembic upgrade head
47
+ ```
48
+
49
+ ### Install and run (using uv):
50
+
51
+ ```sh
52
+ uv sync
53
+ uv run fastapi dev
54
+ ```
umacs-0.7.2/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # UMACS User Management and Access Control Service
2
+
3
+ [![codecov](https://codecov.io/github/csed-ucm/umacs/graph/badge.svg?token=NDVM21XN8W)](https://codecov.io/github/csed-ucm/umacs)
4
+
5
+ UMACS (User Management and Access Control Service) is a lightweight, reusable Authorization service.
6
+
7
+ ## Features
8
+
9
+ - [x] **Policy Engine** based on Casbin for flexible access control, supporting RBAC and ABAC.
10
+ - [x] **Logging** using SQL tables for efficient storage and auditing of decision logs.
11
+ - [x] **JWKS discovery** + **caching** for validating tokens from configured IdPs and rejecting invalid tokens.
12
+ - [x] **API** for managing users, roles, and permissions.
13
+
14
+ ## Core Dependencies
15
+
16
+ The service is built using:
17
+
18
+ - FastAPI + Uvicorn
19
+ - Authlib (OIDC JWT verification)
20
+ - Casbin (RBAC + optional ABAC)
21
+ - SQLModel + AsyncSQLAlchemy (Policies, Users, Resources, Decision Logs)
22
+ - Alembic (Database Migrations)
23
+
24
+ ## Quickstart
25
+
26
+ ### Prerequisites
27
+
28
+ #### Environment
29
+
30
+ Create a `.env` file based on `.env.example`.
31
+
32
+ ### Database Setup
33
+
34
+ Run migrations to set up the SQL database:
35
+
36
+ ```sh
37
+ uv run alembic upgrade head
38
+ ```
39
+
40
+ ### Install and run (using uv):
41
+
42
+ ```sh
43
+ uv sync
44
+ uv run fastapi dev
45
+ ```
@@ -0,0 +1 @@
1
+
File without changes
@@ -0,0 +1,28 @@
1
+ from typing import Optional
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from app.actions.users import authenticate_user
4
+ import jwt
5
+ from app.config import settings
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+
9
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
10
+ to_encode = data.copy()
11
+ if expires_delta:
12
+ expire = datetime.now(timezone.utc) + expires_delta
13
+ else:
14
+ expire = datetime.now(timezone.utc) + timedelta(minutes=15)
15
+ to_encode.update({"exp": expire})
16
+ encoded_jwt = jwt.encode(
17
+ to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM
18
+ )
19
+ return encoded_jwt
20
+
21
+
22
+ async def login(session: AsyncSession, username: str, password: str) -> Optional[dict]:
23
+ user = await authenticate_user(session, username, password)
24
+ if not user:
25
+ return None
26
+
27
+ access_token = create_access_token(data={"sub": user.username})
28
+ return {"access_token": access_token, "token_type": "bearer", "user": user}
@@ -0,0 +1,85 @@
1
+ from typing import List, Optional
2
+ from sqlmodel import select
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from app.tables import Group, GroupMemberLink
5
+ from app.schemas import GroupCreate
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from app.tables import User
10
+
11
+
12
+ async def create_group(session: AsyncSession, group_in: GroupCreate) -> Group:
13
+ db_group = Group(
14
+ name=group_in.name,
15
+ description=group_in.description,
16
+ workspace_id=group_in.workspace_id,
17
+ )
18
+ session.add(db_group)
19
+ await session.commit()
20
+ await session.refresh(db_group)
21
+
22
+ # Add members if provided
23
+ if group_in.member_ids:
24
+ for user_id in group_in.member_ids:
25
+ link = GroupMemberLink(group_id=db_group.id, user_id=user_id)
26
+ session.add(link)
27
+ await session.commit()
28
+ await session.refresh(db_group) # refresh to load members if needed
29
+
30
+ return db_group
31
+
32
+
33
+ async def get_group(session: AsyncSession, group_id: int) -> Optional[Group]:
34
+ return await session.get(Group, group_id)
35
+
36
+
37
+ async def list_groups(session: AsyncSession) -> List[Group]:
38
+ result = await session.execute(select(Group))
39
+ return list(result.scalars().all())
40
+
41
+
42
+ async def delete_group(session: AsyncSession, group_id: int) -> bool:
43
+ group = await session.get(Group, group_id)
44
+ if not group:
45
+ return False
46
+ await session.delete(group)
47
+ await session.commit()
48
+ return True
49
+
50
+
51
+ async def add_user_to_group(session: AsyncSession, group_id: int, user_id: int) -> bool:
52
+ # Check bounds would be good, but simple link insert matches other patterns
53
+ link = GroupMemberLink(group_id=group_id, user_id=user_id)
54
+ session.add(link)
55
+ try:
56
+ await session.commit()
57
+ return True
58
+ except Exception:
59
+ # Likely constraint violation (already member or invalid IDs)
60
+ await session.rollback()
61
+ return False
62
+
63
+
64
+ async def remove_user_from_group(
65
+ session: AsyncSession, group_id: int, user_id: int
66
+ ) -> bool:
67
+ link = await session.get(GroupMemberLink, (group_id, user_id))
68
+ if not link:
69
+ return False
70
+ await session.delete(link)
71
+ await session.commit()
72
+ return True
73
+
74
+
75
+ async def get_group_members(session: AsyncSession, group_id: int) -> List["User"]:
76
+ # Depending on output, maybe return User objects?
77
+ from app.tables import User
78
+
79
+ stmt = (
80
+ select(User)
81
+ .join(GroupMemberLink, User.id == GroupMemberLink.user_id) # type: ignore[arg-type]
82
+ .where(GroupMemberLink.group_id == group_id)
83
+ )
84
+ result = await session.execute(stmt)
85
+ return list(result.scalars().all())
@@ -0,0 +1,27 @@
1
+ from typing import List
2
+ from app.authz import get_enforcer
3
+
4
+ # Policy structure: sub, obj, act (from rbac_model.conf)
5
+
6
+
7
+ async def add_policy(sub: str, obj: str, act: str, meta: str = "") -> bool:
8
+ e = await get_enforcer()
9
+ return await e.add_policy(sub, obj, act, meta) # type: ignore[no-any-return]
10
+
11
+
12
+ async def remove_policy(sub: str, obj: str, act: str) -> bool:
13
+ e = await get_enforcer()
14
+ # Use remove_filtered_policy to match sub, obj, act (indices 0, 1, 2)
15
+ # and ignore metadata (index 3).
16
+ # remove_filtered_policy(field_index, *field_values)
17
+ return await e.remove_filtered_policy(0, sub, obj, act) # type: ignore[no-any-return]
18
+
19
+
20
+ async def get_policies() -> List[List[str]]:
21
+ e = await get_enforcer()
22
+ return e.get_policy() # type: ignore[no-any-return]
23
+
24
+
25
+ async def get_policies_for_subject(sub: str) -> List[List[str]]:
26
+ e = await get_enforcer()
27
+ return e.get_filtered_policy(0, sub) # type: ignore[no-any-return]
@@ -0,0 +1,36 @@
1
+ from typing import List
2
+ from sqlmodel import select
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from app.tables import Resource
5
+ from app.schemas import ResourceCreate
6
+
7
+
8
+ async def create_resource(
9
+ session: AsyncSession, resource_in: ResourceCreate
10
+ ) -> Resource:
11
+ db_resource = Resource(
12
+ name=resource_in.name,
13
+ workspace_id=resource_in.workspace_id,
14
+ type=resource_in.type,
15
+ )
16
+ session.add(db_resource)
17
+ await session.commit()
18
+ await session.refresh(db_resource)
19
+ return db_resource
20
+
21
+
22
+ async def list_resources(session: AsyncSession) -> List[Resource]:
23
+ result = await session.execute(select(Resource))
24
+ return list(result.scalars().all())
25
+
26
+
27
+ async def delete_resource_by_name(session: AsyncSession, name: str) -> bool:
28
+ # Logic to delete by name (as per old endpoint style)
29
+ stmt = select(Resource).where(Resource.name == name)
30
+ result = await session.execute(stmt)
31
+ resource = result.scalars().first()
32
+ if not resource:
33
+ return False
34
+ await session.delete(resource)
35
+ await session.commit()
36
+ return True
@@ -0,0 +1,27 @@
1
+ from typing import List
2
+ from app.authz import get_enforcer
3
+
4
+
5
+ async def add_role_for_user(user: str, role: str) -> bool:
6
+ e = await get_enforcer()
7
+ return await e.add_grouping_policy(user, role) # type: ignore[no-any-return]
8
+
9
+
10
+ async def remove_role_for_user(user: str, role: str) -> bool:
11
+ e = await get_enforcer()
12
+ return await e.remove_grouping_policy(user, role) # type: ignore[no-any-return]
13
+
14
+
15
+ async def get_roles_for_user(user: str) -> List[str]:
16
+ e = await get_enforcer()
17
+ return await e.get_roles_for_user(user) # type: ignore[no-any-return]
18
+
19
+
20
+ async def get_users_for_role(role: str) -> List[str]:
21
+ e = await get_enforcer()
22
+ return await e.get_users_for_role(role) # type: ignore[no-any-return]
23
+
24
+
25
+ async def get_all_roles() -> List[str]:
26
+ e = await get_enforcer()
27
+ return await e.get_all_roles() # type: ignore[no-any-return]
@@ -0,0 +1,111 @@
1
+ from typing import Optional
2
+ from sqlmodel import select
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from app.tables import User
5
+ from app.schemas import UserCreate
6
+ from app.utils.auth.password import hash_password, verify_password
7
+
8
+
9
+ async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
10
+ stmt = select(User).where(User.username == username)
11
+ result = await session.execute(stmt)
12
+ return result.scalar_one_or_none()
13
+
14
+
15
+ async def create_user(session: AsyncSession, user_in: UserCreate) -> User:
16
+ if await get_user_by_username(session, user_in.username):
17
+ raise ValueError("Username already exists")
18
+
19
+ hashed = hash_password(user_in.password)
20
+ db_user = User(
21
+ username=user_in.username,
22
+ email=user_in.email,
23
+ name=user_in.name,
24
+ hashed_password=hashed,
25
+ external_idp_id=None,
26
+ )
27
+ session.add(db_user)
28
+ await session.commit()
29
+ await session.refresh(db_user)
30
+ return db_user
31
+
32
+
33
+ async def authenticate_user(
34
+ session: AsyncSession, username: str, password: str
35
+ ) -> Optional[User]:
36
+ user = await get_user_by_username(session, username)
37
+ if not user:
38
+ return None
39
+ if not user.hashed_password:
40
+ return None
41
+ if verify_password(password, user.hashed_password):
42
+ return user
43
+ return None
44
+
45
+
46
+ async def get_user_by_identity(
47
+ session: AsyncSession, iss: str, sub: str
48
+ ) -> Optional[User]:
49
+ """Find user by OIDC identity (issuer + subject)"""
50
+ stmt = select(User).where(User.iss == iss, User.sub == sub)
51
+ result = await session.execute(stmt)
52
+ return result.scalar_one_or_none()
53
+
54
+
55
+ async def upsert_user(
56
+ session: AsyncSession,
57
+ iss: str,
58
+ sub: str,
59
+ email: str,
60
+ username: str,
61
+ name: str = "",
62
+ claims: Optional[str] = None,
63
+ ) -> User:
64
+ """
65
+ Create or update a user based on OIDC identity.
66
+ Atomic-like behavior: checks existence first.
67
+ """
68
+ # 1. Try to find by identity
69
+ user = await get_user_by_identity(session, iss, sub)
70
+
71
+ if user:
72
+ # Update existing user
73
+ # Avoid overwriting PII if not changed? SQLModel updates dirty fields.
74
+ user.email = email
75
+ user.name = name
76
+ if claims:
77
+ user.claims = claims
78
+ # Don't change username - it's a stable identifier locally
79
+ else:
80
+ # 2. Check if username is taken (by legacy account?)
81
+ # If taken, we might need a fallback strategy (e.g. append random suffix)
82
+ # For now, simplistic approach: fail or reuse?
83
+ # Better: JIT provisioning should tolerate username collisions by suffixing.
84
+ # But for this iteration, let's assume unique usernames provided by mapping or fail.
85
+ existing_username = await get_user_by_username(session, username)
86
+ if existing_username:
87
+ # Strategy: link if email matches? Or generic error?
88
+ # If emails match, we COULD link accounts.
89
+ # Let's link if email matches, else error.
90
+ if existing_username.email == email:
91
+ user = existing_username
92
+ user.iss = iss
93
+ user.sub = sub
94
+ user.claims = claims
95
+ else:
96
+ raise ValueError(f"Username {username} already taken by another user")
97
+ else:
98
+ # Create new
99
+ user = User(
100
+ username=username,
101
+ email=email,
102
+ name=name,
103
+ iss=iss,
104
+ sub=sub,
105
+ claims=claims,
106
+ )
107
+ session.add(user)
108
+
109
+ await session.commit()
110
+ await session.refresh(user)
111
+ return user
@@ -0,0 +1,37 @@
1
+ from typing import List, Optional
2
+ from sqlmodel import select
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from app.tables import Workspace, WorkspaceMemberLink
5
+ from app.schemas import WorkspaceCreate
6
+
7
+
8
+ async def create_workspace(
9
+ session: AsyncSession, workspace_in: WorkspaceCreate, owner_id: int
10
+ ) -> Workspace:
11
+ db_workspace = Workspace(name=workspace_in.name, owner_id=owner_id)
12
+ session.add(db_workspace)
13
+ await session.commit()
14
+ await session.refresh(db_workspace)
15
+ return db_workspace
16
+
17
+
18
+ async def get_workspace(
19
+ session: AsyncSession, workspace_id: int
20
+ ) -> Optional[Workspace]:
21
+ return await session.get(Workspace, workspace_id)
22
+
23
+
24
+ async def list_workspaces(session: AsyncSession) -> List[Workspace]:
25
+ # Placeholder: currently listing all. Logic usually entails listing only accessible ones.
26
+ stmt = select(Workspace)
27
+ result = await session.execute(stmt)
28
+ return list(result.scalars().all())
29
+
30
+
31
+ async def add_workspace_member(
32
+ session: AsyncSession, workspace_id: int, user_id: int
33
+ ) -> None:
34
+ # Check existence logic omitted for brevity, usually handled or throws FK error
35
+ link = WorkspaceMemberLink(workspace_id=workspace_id, user_id=user_id)
36
+ session.add(link)
37
+ await session.commit()
@@ -0,0 +1,14 @@
1
+ # # Initializing routers, importing key modules, and register routes into main router
2
+
3
+ # from fastapi import FastAPI
4
+ # from . import policies
5
+ # from ...schemas import users
6
+ # from . import roles
7
+ # from . import auth
8
+
9
+ # app = FastAPI()
10
+
11
+ # app.include_router(policies.router, prefix="/policies", tags=["Policies"])
12
+ # app.include_router(users.router, prefix="/user", tags=["Users"])
13
+ # app.include_router(roles.router, prefix="/roles", tags=["Roles"])
14
+ # app.include_router(auth.router, prefix="/auth", tags=["Auth"])
@@ -0,0 +1,40 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from fastapi.security import OAuth2PasswordRequestForm
4
+
5
+ from app.utils.db import get_session
6
+ from app.actions import auth as auth_actions
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.post("/login")
12
+ async def login(
13
+ form_data: OAuth2PasswordRequestForm = Depends(),
14
+ session: AsyncSession = Depends(get_session),
15
+ ) -> dict:
16
+ token = await auth_actions.login(session, form_data.username, form_data.password)
17
+ if not token:
18
+ raise HTTPException(
19
+ status_code=status.HTTP_401_UNAUTHORIZED,
20
+ detail="Incorrect username or password",
21
+ headers={"WWW-Authenticate": "Bearer"},
22
+ )
23
+ return token
24
+
25
+
26
+ @router.post("/register", status_code=status.HTTP_200_OK)
27
+ async def register(
28
+ user_data: dict, # Or UserCreate, but let's stick to dict for flexibility or import UserCreate
29
+ session: AsyncSession = Depends(get_session),
30
+ ) -> dict:
31
+ from app.schemas.user import UserCreate
32
+ from app.actions.users import create_user
33
+
34
+ # Adapt validation
35
+ try:
36
+ user_in = UserCreate(**user_data)
37
+ await create_user(session, user_in)
38
+ return {"message": "User created successfully"}
39
+ except Exception as e:
40
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,21 @@
1
+ from fastapi import APIRouter, Depends
2
+ from app.authz import get_enforcer
3
+ from app.utils.db import get_session
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from app.schemas import EnforceRequest, EnforceResponse
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.post("/check", response_model=EnforceResponse)
11
+ async def check_permission(
12
+ req: EnforceRequest, session: AsyncSession = Depends(get_session)
13
+ ) -> EnforceResponse:
14
+ e = await get_enforcer()
15
+ allowed = e.enforce(req.sub, req.obj, req.act)
16
+
17
+ from app.utils.logging import log_decision
18
+
19
+ await log_decision(sub=req.sub, obj=req.obj, act=req.act, authorized=allowed)
20
+
21
+ return EnforceResponse(allow=allowed)
@@ -0,0 +1,60 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from typing import List
4
+
5
+ from app.utils.db import get_session
6
+ from app.schemas import GroupCreate, GroupOut, UserRead
7
+ from app.actions import groups as group_actions
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.post("/", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
13
+ async def create_group(
14
+ group: GroupCreate, session: AsyncSession = Depends(get_session)
15
+ ) -> GroupOut:
16
+ return await group_actions.create_group(session, group) # type: ignore[return-value]
17
+
18
+
19
+ @router.get("/", response_model=List[GroupOut])
20
+ async def list_groups(session: AsyncSession = Depends(get_session)) -> List[GroupOut]:
21
+ return await group_actions.list_groups(session) # type: ignore[return-value]
22
+
23
+
24
+ @router.get("/{group_id}", response_model=GroupOut)
25
+ async def get_group(
26
+ group_id: int, session: AsyncSession = Depends(get_session)
27
+ ) -> GroupOut:
28
+ group = await group_actions.get_group(session, group_id)
29
+ if not group:
30
+ raise HTTPException(status_code=404, detail="Group not found")
31
+ return group # type: ignore[return-value]
32
+
33
+
34
+ @router.delete("/{group_id}")
35
+ async def delete_group(
36
+ group_id: int, session: AsyncSession = Depends(get_session)
37
+ ) -> dict:
38
+ success = await group_actions.delete_group(session, group_id)
39
+ if not success:
40
+ raise HTTPException(status_code=404, detail="Group not found")
41
+ return {"detail": "Group deleted"}
42
+
43
+
44
+ @router.post("/{group_id}/users/{user_id}")
45
+ async def add_user_to_group(
46
+ group_id: int, user_id: int, session: AsyncSession = Depends(get_session)
47
+ ) -> dict:
48
+ success = await group_actions.add_user_to_group(session, group_id, user_id)
49
+ if not success:
50
+ raise HTTPException(
51
+ status_code=400, detail="Could not add user (already member?)"
52
+ )
53
+ return {"detail": "User added to group"}
54
+
55
+
56
+ @router.get("/{group_id}/users", response_model=List[UserRead])
57
+ async def list_group_members(
58
+ group_id: int, session: AsyncSession = Depends(get_session)
59
+ ) -> List[UserRead]:
60
+ return await group_actions.get_group_members(session, group_id) # type: ignore[return-value]
@@ -0,0 +1,47 @@
1
+ from fastapi import APIRouter, File, UploadFile, HTTPException
2
+ from app.authz import get_enforcer, BASE_DIR
3
+ import os
4
+ import shutil
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ @router.post("/upload")
10
+ async def upload_model(file: UploadFile = File(...)) -> dict:
11
+ if not file.filename or not file.filename.endswith(".conf"):
12
+ raise HTTPException(status_code=400, detail="Only .conf files are allowed.")
13
+
14
+ # Securely save the file
15
+ # For simplicity, we overwrite a "dynamic_model.conf" or similar,
16
+ # but the user might want to replace "rbac_model.conf".
17
+ # Let's save it as "custom_model.conf" and reload.
18
+
19
+ target_path = os.path.join(BASE_DIR, "custom_model.conf")
20
+
21
+ try:
22
+ with open(target_path, "wb") as buffer:
23
+ shutil.copyfileobj(file.file, buffer)
24
+ except Exception as e:
25
+ raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
26
+
27
+ # Reload Enforcer with new model
28
+ try:
29
+ _ = await get_enforcer("custom_model.conf")
30
+ # get_enforcer needs to support swapping models dynamically.
31
+ # Currently it defaults to "rbac_model.conf".
32
+ # We need to ensure get_enforcer can load this new file.
33
+ # My implementation of get_enforcer takes optional `model_file`.
34
+ # But `e` is a singleton in app.state usually, or fresh every time?
35
+ # get_enforcer creates a new instance every time in my impl.
36
+ # This is fine for per-request, but we might want a global one?
37
+ # For now, this confirms the model is valid.
38
+ pass
39
+ except Exception as e:
40
+ # If invalid, revert?
41
+ os.remove(target_path)
42
+ raise HTTPException(status_code=400, detail=f"Invalid model content: {str(e)}")
43
+
44
+ return {
45
+ "message": "Model uploaded and verified.",
46
+ "model_file": "custom_model.conf",
47
+ }
@@ -0,0 +1,36 @@
1
+ from app.actions import policies as policy_actions
2
+ from app.schemas import PolicyCreate
3
+ from typing import List
4
+ from fastapi import APIRouter, HTTPException
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ # Adding policies
10
+ @router.post("/add", response_model=bool)
11
+ async def add_policy(payload: PolicyCreate) -> bool:
12
+ meta_json = payload.get_metadata_json()
13
+ return await policy_actions.add_policy(
14
+ payload.sub, payload.obj, payload.act, meta_json
15
+ )
16
+
17
+
18
+ # Listing all policies
19
+ @router.get("/", response_model=List[List[str]])
20
+ async def list_policies() -> List[List[str]]:
21
+ return await policy_actions.get_policies()
22
+
23
+
24
+ # Delete a policy
25
+ @router.delete("/remove", response_model=bool)
26
+ async def delete_policy(sub: str, obj: str, act: str) -> bool:
27
+ removed = await policy_actions.remove_policy(sub, obj, act)
28
+ if not removed:
29
+ raise HTTPException(status_code=404, detail="Policy not found")
30
+ return removed
31
+
32
+
33
+ # Retrieving policies for a subject
34
+ @router.get("/subject/{sub}", response_model=List[List[str]])
35
+ async def get_policy_for_subject(sub: str) -> List[List[str]]:
36
+ return await policy_actions.get_policies_for_subject(sub)