Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Python application

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
service: [auth-service, engine]

steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v4
with:
python-version: "3.12"

- name: Install dependencies
working-directory: ./${{ matrix.service }}
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi

- name: Lint with flake8
working-directory: ./${{ matrix.service }}
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings.
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

- name: Test with pytest
working-directory: ./${{ matrix.service }}
run: |
pytest
12 changes: 10 additions & 2 deletions README.ja.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Dynaman - 動的スキーマ PoC
# Dynaman - Dynamic Schema PoC

[Read in English](README.md)
[![Python application](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml/badge.svg)](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml)

[English](README.md)

**Dynaman** は、現代の **NoCode/LowCode プラットフォーム** における中心的なパターンである **未固定かつ動的なスキーマ** のアーキテクチャと実装を実証するために設計された Proof of Concept (PoC) プロジェクトです。

Expand Down Expand Up @@ -31,6 +33,8 @@
### インフラストラクチャ
* **コンテナ化**: Docker & Docker Compose
* **ゲートウェイ**: Nginx (リバースプロキシ)
* **IaC**: Terraform (Infrastructure as Code)
* **クラウドプロバイダー**: AWS (ECS Fargate/EC2, ALB, ECR, CodePipeline)

### フロントエンド (`/dynaman-ui`)
* **フレームワーク**: [React](https://react.dev/) (with Vite)
Expand Down Expand Up @@ -81,6 +85,10 @@ dynaman/
│ │ ├── pages/ # アプリケーションビュー
│ │ └── context/ # [NEW] AuthContext
├── infrastructure/ # [NEW] Terraform & AWS 設定
│ ├── terraform/ # IaC 定義
│ └── README.md # デプロイガイド
├── docker-compose.yml # [NEW] コンテナオーケストレーション
└── nginx-gateway.conf # [NEW] API ゲートウェイ設定
```
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Dynaman - Dynamic Schema PoC

[![Python application](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml/badge.svg)](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml)

[日本語で読む](README.ja.md)

**Dynaman** is a Proof of Concept (PoC) project designed to demonstrate the architecture and implementation of **unfixed, dynamic schemas**—a core pattern found in modern **NoCode/LowCode platforms**.
Expand Down Expand Up @@ -31,6 +33,8 @@ The primary goal is to showcase how to handle **User-Defined Requirements** wher
### Infrastructure
* **Containerization**: Docker & Docker Compose
* **Gateway**: Nginx (Reverse Proxy)
* **IaC**: Terraform (Infrastructure as Code)
* **Cloud Provider**: AWS (ECS Fargate/EC2, ALB, ECR, CodePipeline)

### Frontend (`/dynaman-ui`)
* **Framework**: [React](https://react.dev/) (with Vite)
Expand Down Expand Up @@ -81,6 +85,10 @@ dynaman/
│ │ ├── pages/ # Application Views
│ │ └── context/ # [NEW] AuthContext
├── infrastructure/ # [NEW] Terraform & AWS Configuration
│ ├── terraform/ # IaC Definitions
│ └── README.md # Deployment Guide
├── docker-compose.yml # [NEW] Container Orchestration
└── nginx-gateway.conf # [NEW] API Gateway Config
```
Expand Down
4 changes: 4 additions & 0 deletions auth-service/api/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from motor.motor_asyncio import AsyncIOMotorClient
from config import settings
from infrastructure.user_repository import UserRepository
from infrastructure.user_group_repository import UserGroupRepository
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
Expand All @@ -17,6 +18,9 @@ async def get_db():
async def get_user_repository(db = Depends(get_db)):
return UserRepository(db)

async def get_user_group_repository(db = Depends(get_db)):
return UserGroupRepository(db)

async def get_current_user(
token: str = Depends(oauth2_scheme),
user_repo: UserRepository = Depends(get_user_repository)
Expand Down
35 changes: 33 additions & 2 deletions auth-service/api/v1/router_auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from domain.entities.user import User, UserCreate, UserRole
from domain.entities.user import User, UserCreate, UserUpdate, UserRole
from application.auth_use_cases import AuthUseCases
from infrastructure.user_repository import UserRepository
from api.dependencies import get_user_repository, get_current_user
Expand Down Expand Up @@ -48,6 +48,37 @@ async def list_users(
use_cases = AuthUseCases(user_repo)
return await use_cases.list_users()

@router.put("/users/{user_id}", response_model=User)
async def update_user(
user_id: str,
user_in: UserUpdate,
user_repo: Annotated[UserRepository, Depends(get_user_repository)],
current_user: Annotated[User, Depends(get_current_user)]
):
if current_user.role == UserRole.USER:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update users"
)

use_cases = AuthUseCases(user_repo)
target_user = await user_repo.get_by_id(user_id)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")

# Permission checks
if current_user.role == UserRole.USER_ADMIN:
if target_user.role == UserRole.SYSTEM_ADMIN:
raise HTTPException(status_code=403, detail="User Admins cannot update System Admins")
if user_in.role == UserRole.SYSTEM_ADMIN:
raise HTTPException(status_code=403, detail="User Admins cannot promote to System Admin")

updated_user = await use_cases.update_user(user_id, user_in)
if not updated_user:
raise HTTPException(status_code=404, detail="User not found")

return updated_user

@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
Expand Down Expand Up @@ -90,5 +121,5 @@ async def login(
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = SecurityService.create_access_token(data={"sub": user.email, "role": user.role})
access_token = SecurityService.create_access_token(data={"sub": user.email, "role": user.role, "groups": user.group_ids})
return {"access_token": access_token, "token_type": "bearer"}
70 changes: 70 additions & 0 deletions auth-service/api/v1/router_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Annotated
from domain.entities.user import User, UserRole
from domain.entities.user_group import UserGroup, UserGroupCreate, UserGroupUpdate
from infrastructure.user_group_repository import UserGroupRepository
from api.dependencies import get_user_group_repository, get_current_user

router = APIRouter()

def require_system_admin(current_user: User = Depends(get_current_user)):
if current_user.role != UserRole.SYSTEM_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires System Admin privileges"
)
return current_user

@router.post("", response_model=UserGroup, status_code=status.HTTP_201_CREATED)
async def create_group(
group_in: UserGroupCreate,
repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)],
_: Annotated[User, Depends(require_system_admin)]
):
existing = await repo.get_by_name(group_in.name)
if existing:
raise HTTPException(status_code=400, detail="Group with this name already exists")

group = UserGroup(**group_in.model_dump())
return await repo.create(group)

@router.get("", response_model=list[UserGroup])
async def list_groups(
repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)],
_: Annotated[User, Depends(require_system_admin)]
):
return await repo.get_all()

@router.get("/{group_id}", response_model=UserGroup)
async def get_group(
group_id: str,
repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)],
_: Annotated[User, Depends(require_system_admin)]
):
group = await repo.get_by_id(group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
return group

@router.put("/{group_id}", response_model=UserGroup)
async def update_group(
group_id: str,
group_in: UserGroupUpdate,
repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)],
_: Annotated[User, Depends(require_system_admin)]
):
updated_group = await repo.update(group_id, group_in.model_dump(exclude_unset=True))
if not updated_group:
raise HTTPException(status_code=404, detail="Group not found")
return updated_group

@router.delete("/{group_id}")
async def delete_group(
group_id: str,
repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)],
_: Annotated[User, Depends(require_system_admin)]
):
success = await repo.delete(group_id)
if not success:
raise HTTPException(status_code=404, detail="Group not found")
return {"message": "Group deleted successfully"}
14 changes: 12 additions & 2 deletions auth-service/application/auth_use_cases.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from domain.entities.user import User, UserCreate
from domain.entities.user import User, UserCreate, UserUpdate
from domain.services.security_service import SecurityService
from infrastructure.user_repository import UserRepository
from fastapi import HTTPException, status
Expand All @@ -19,7 +19,8 @@ async def register_user(self, user_in: UserCreate):
new_user = User(
email=user_in.email,
hashed_password=hashed_password,
role=user_in.role
role=user_in.role,
group_ids=user_in.group_ids
)
created_user = await self.user_repo.create(new_user)
return created_user
Expand All @@ -35,5 +36,14 @@ async def authenticate_user(self, email: str, password: str):
async def list_users(self):
return await self.user_repo.get_all()

async def update_user(self, user_id: str, user_in: UserUpdate):
update_data = user_in.model_dump(exclude_unset=True)
if "password" in update_data:
hashed_password = SecurityService.get_password_hash(update_data["password"])
update_data["hashed_password"] = hashed_password
del update_data["password"]

return await self.user_repo.update(user_id, update_data)

async def delete_user(self, user_id: str):
return await self.user_repo.delete(user_id)
9 changes: 9 additions & 0 deletions auth-service/domain/entities/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class User(BaseModel):
hashed_password: str
is_active: bool = True
role: UserRole = UserRole.USER
group_ids: list[str] = Field(default_factory=list, description="List of UserGroup IDs this user belongs to")
provider: str = "local" # local, google, etc.

model_config = ConfigDict(
Expand All @@ -29,3 +30,11 @@ class UserCreate(BaseModel):
email: EmailStr
password: str
role: UserRole = UserRole.USER
group_ids: list[str] = Field(default_factory=list)

class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
password: Optional[str] = None
is_active: Optional[bool] = None
role: Optional[UserRole] = None
group_ids: Optional[list[str]] = None
25 changes: 25 additions & 0 deletions auth-service/domain/entities/user_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pydantic import BaseModel, Field, BeforeValidator, ConfigDict
from typing import Optional, Annotated
from bson import ObjectId

# Helper for Pydantic v2 + BSON
PyObjectId = Annotated[str, BeforeValidator(str)]

class UserGroup(BaseModel):
id: Optional[PyObjectId] = Field(alias="_id", default=None)
name: str = Field(..., min_length=1, description="Unique name of the group (e.g., 'Sales')")
description: Optional[str] = Field(default=None, description="Description of the group's purpose")

model_config = ConfigDict(
populate_by_name=True,
arbitrary_types_allowed=True,
json_encoders={ObjectId: str}
)

class UserGroupCreate(BaseModel):
name: str
description: Optional[str] = None

class UserGroupUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
65 changes: 65 additions & 0 deletions auth-service/infrastructure/user_group_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from motor.motor_asyncio import AsyncIOMotorDatabase
from domain.entities.user_group import UserGroup
from bson import ObjectId

class UserGroupRepository:
def __init__(self, db: AsyncIOMotorDatabase):
self.collection = db["user_groups"]

async def get_by_id(self, group_id: str):
try:
oid = ObjectId(group_id)
except Exception:
return None
doc = await self.collection.find_one({"_id": oid})
if doc:
return UserGroup(**doc)
return None

async def get_by_name(self, name: str):
doc = await self.collection.find_one({"name": name})
if doc:
return UserGroup(**doc)
return None

async def get_all(self):
cursor = self.collection.find()
groups = []
async for doc in cursor:
groups.append(UserGroup(**doc))
return groups

async def create(self, group: UserGroup):
group_dict = group.model_dump(by_alias=True, exclude={"id"})
result = await self.collection.insert_one(group_dict)
group.id = str(result.inserted_id)
return group

async def update(self, group_id: str, update_data: dict):
# update_data should match UserGroupUpdate fields
if not update_data:
return None

try:
oid = ObjectId(group_id)
except Exception:
return None

result = await self.collection.update_one(
{"_id": oid},
{"$set": update_data}
)
if result.modified_count > 0:
return await self.get_by_id(group_id)
# If no document matched, return None. If matched but no change, fetch and return.
if result.matched_count > 0:
return await self.get_by_id(group_id)
return None

async def delete(self, group_id: str):
try:
oid = ObjectId(group_id)
except Exception:
return False
result = await self.collection.delete_one({"_id": oid})
return result.deleted_count > 0
Loading