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.
- umacs-0.7.2/PKG-INFO +54 -0
- umacs-0.7.2/README.md +45 -0
- umacs-0.7.2/app/__init__.py +1 -0
- umacs-0.7.2/app/actions/__init__.py +0 -0
- umacs-0.7.2/app/actions/auth.py +28 -0
- umacs-0.7.2/app/actions/groups.py +85 -0
- umacs-0.7.2/app/actions/policies.py +27 -0
- umacs-0.7.2/app/actions/resources.py +36 -0
- umacs-0.7.2/app/actions/roles.py +27 -0
- umacs-0.7.2/app/actions/users.py +111 -0
- umacs-0.7.2/app/actions/workspaces.py +37 -0
- umacs-0.7.2/app/api/__init__.py +14 -0
- umacs-0.7.2/app/api/auth.py +40 -0
- umacs-0.7.2/app/api/enforce.py +21 -0
- umacs-0.7.2/app/api/groups.py +60 -0
- umacs-0.7.2/app/api/models_route.py +47 -0
- umacs-0.7.2/app/api/policies.py +36 -0
- umacs-0.7.2/app/api/resources.py +35 -0
- umacs-0.7.2/app/api/roles.py +41 -0
- umacs-0.7.2/app/api/users.py +50 -0
- umacs-0.7.2/app/api/workspaces.py +50 -0
- umacs-0.7.2/app/app.py +89 -0
- umacs-0.7.2/app/authz.py +27 -0
- umacs-0.7.2/app/cli.py +21 -0
- umacs-0.7.2/app/config.py +38 -0
- umacs-0.7.2/app/exceptions/__init__.py +0 -0
- umacs-0.7.2/app/rbac_model.conf +26 -0
- umacs-0.7.2/app/schemas/__init__.py +34 -0
- umacs-0.7.2/app/schemas/auth.py +32 -0
- umacs-0.7.2/app/schemas/decision.py +14 -0
- umacs-0.7.2/app/schemas/enforce.py +11 -0
- umacs-0.7.2/app/schemas/group.py +27 -0
- umacs-0.7.2/app/schemas/policy.py +62 -0
- umacs-0.7.2/app/schemas/resource.py +19 -0
- umacs-0.7.2/app/schemas/role.py +11 -0
- umacs-0.7.2/app/schemas/user.py +33 -0
- umacs-0.7.2/app/schemas/workspace.py +23 -0
- umacs-0.7.2/app/tables.py +180 -0
- umacs-0.7.2/app/utils/__init__.py +0 -0
- umacs-0.7.2/app/utils/auth/__init__.py +0 -0
- umacs-0.7.2/app/utils/auth/deps.py +33 -0
- umacs-0.7.2/app/utils/auth/middleware.py +136 -0
- umacs-0.7.2/app/utils/auth/password.py +52 -0
- umacs-0.7.2/app/utils/casbin_middleware.py +66 -0
- umacs-0.7.2/app/utils/db.py +26 -0
- umacs-0.7.2/app/utils/logging/__init__.py +4 -0
- umacs-0.7.2/app/utils/logging/audit.py +78 -0
- umacs-0.7.2/app/utils/logging/formatter.py +48 -0
- umacs-0.7.2/app/utils/sso/__init__.py +0 -0
- umacs-0.7.2/app/utils/sso/claims.py +63 -0
- umacs-0.7.2/app/utils/sso/oidc.py +152 -0
- umacs-0.7.2/app/utils/sso/scopes.py +36 -0
- umacs-0.7.2/pyproject.toml +108 -0
- umacs-0.7.2/setup.cfg +4 -0
- umacs-0.7.2/umacs/client.py +214 -0
- umacs-0.7.2/umacs.egg-info/PKG-INFO +54 -0
- umacs-0.7.2/umacs.egg-info/SOURCES.txt +58 -0
- umacs-0.7.2/umacs.egg-info/dependency_links.txt +1 -0
- umacs-0.7.2/umacs.egg-info/requires.txt +2 -0
- 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
|
+
[](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
|
+
[](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)
|