Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2226cd923 | ||
|
|
7bf3c76a1b | ||
|
|
4ae8cfbc0b | ||
|
|
69045a78a7 | ||
|
|
884996fd83 | ||
|
|
8bd13eab09 |
@@ -1,12 +1,25 @@
|
|||||||
name: Build and Push
|
name: Build and Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
push:
|
||||||
types: [published]
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create Gitea Release
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ When changes are ready for deployment:
|
|||||||
5. **CI/CD triggers automatically**:
|
5. **CI/CD triggers automatically**:
|
||||||
- Gitea CI builds Docker image on new tag
|
- Gitea CI builds Docker image on new tag
|
||||||
- Watchtower pulls and deploys to production
|
- Watchtower pulls and deploys to production
|
||||||
- Verify deployment: `curl http://192.168.86.149:8000/health`
|
- Verify deployment: `curl http://192.168.86.149:8083/health`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- CORS configuration now uses explicit origins instead of `"*"`
|
||||||
|
- When `allow_credentials=True`, wildcard origins are rejected by browsers
|
||||||
|
- Added `home.schweitz.net`, `tatlock.schweitz.net`, and localhost origins
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **NPM Forward Auth Support** - Web authentication via Nginx Proxy Manager forward auth
|
||||||
|
- `GET /auth/me` - Get current user from NPM forward auth headers (X-authentik-uid, X-authentik-email, etc.)
|
||||||
|
- Auto-creates user on first web login if not in database
|
||||||
|
- Syncs roles from NPM forward auth groups header
|
||||||
|
- `get_user_by_email` and `get_user_by_authentik_id` methods in AuthService
|
||||||
|
- Comprehensive tests for `/auth/me` endpoint
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Group-Role Mapping & Permissions** (Phase 3)
|
||||||
|
- Decoupled group-role architecture (groups from Authentik, roles admin-managed)
|
||||||
|
- Permission format: `domain.category:action` with action hierarchy
|
||||||
|
- `require_permission` and `require_any_permission` dependency factories
|
||||||
|
- Global admin override (`admin.general:admin`)
|
||||||
|
- **User Profile & API Keys** (Phase 4)
|
||||||
|
- `GET /auth/users/me` - Full user profile with roles and preferences
|
||||||
|
- `GET/PATCH /auth/users/me/preferences` - User preferences management
|
||||||
|
- `GET/POST/DELETE /auth/users/me/api-keys` - API key lifecycle
|
||||||
|
- API keys with `tak_` prefix, SHA-256 hashing, shown only once on creation
|
||||||
|
|
||||||
## [1.7.0] - 2026-01-03
|
## [1.7.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.7.0"
|
version = "1.9.1"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+70
-7
@@ -13,6 +13,7 @@ from src.logging_config import get_logger
|
|||||||
from src.db import get_async_session
|
from src.db import get_async_session
|
||||||
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
||||||
from src.auth.service import AuthService
|
from src.auth.service import AuthService
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -222,21 +223,83 @@ class AuthController(BaseController):
|
|||||||
responses={
|
responses={
|
||||||
200: {"description": "User profile"},
|
200: {"description": "User profile"},
|
||||||
401: {"description": "Not authenticated"},
|
401: {"description": "Not authenticated"},
|
||||||
|
404: {"description": "User not found in database"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
|
forward_auth_user: Optional[dict] = Depends(get_forward_auth_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> JSONResponse:
|
) -> AuthSyncResponse:
|
||||||
"""
|
"""
|
||||||
Get the current authenticated user's profile
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
Note: This endpoint requires a valid session or API key.
|
Authentication is handled by NPM forward auth with Authentik.
|
||||||
For now, returns 501 Not Implemented until session management is added.
|
The proxy sets X-authentik-* headers which this endpoint reads.
|
||||||
|
|
||||||
|
For internal/LAN access (no forward auth headers), returns 401.
|
||||||
|
Use POST /auth/sync with an OIDC token for mobile app authentication.
|
||||||
"""
|
"""
|
||||||
# TODO: Implement with get_current_user dependency
|
# Require forward auth for this endpoint
|
||||||
raise HTTPException(
|
if forward_auth_user is None:
|
||||||
status_code=501,
|
raise HTTPException(
|
||||||
detail="Not implemented - use /auth/sync with access token",
|
status_code=401,
|
||||||
|
detail="Authentication required - access via authenticated proxy or use /auth/sync",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
# Try to find user by Authentik UID first, then by email
|
||||||
|
user = None
|
||||||
|
uid = forward_auth_user.get("uid")
|
||||||
|
if uid:
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
authentik_id = uuid.UUID(uid)
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Invalid UUID, try email
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
email = forward_auth_user.get("email")
|
||||||
|
if email:
|
||||||
|
user = await service.get_user_by_email(email)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
# User authenticated with Authentik but not synced to database yet
|
||||||
|
# This can happen on first login via web
|
||||||
|
logger.info(f"User {forward_auth_user.get('email')} not found, creating from forward auth")
|
||||||
|
|
||||||
|
# Create user from forward auth headers
|
||||||
|
from src.auth.schemas import TokenInfoSchema
|
||||||
|
token_info = TokenInfoSchema(
|
||||||
|
sub=forward_auth_user.get("uid", ""),
|
||||||
|
email=forward_auth_user.get("email", ""),
|
||||||
|
name=forward_auth_user.get("name"),
|
||||||
|
groups=forward_auth_user.get("groups", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user, _ = await service.sync_user(token_info)
|
||||||
|
await service.sync_roles(user, token_info.groups)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user, ["preferences", "roles"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create user from forward auth: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to create user profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync roles from current groups (in case they changed)
|
||||||
|
groups = forward_auth_user.get("groups", [])
|
||||||
|
roles = await service.sync_roles(user, groups)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return AuthSyncResponse(
|
||||||
|
user=service.user_to_schema(user),
|
||||||
|
roles=service.roles_to_schema(roles),
|
||||||
|
preferences=service.preferences_to_schema(user.preferences),
|
||||||
|
is_new_user=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -86,6 +86,42 @@ class AuthService:
|
|||||||
logger.error(f"Authentik userinfo request error: {e}")
|
logger.error(f"Authentik userinfo request error: {e}")
|
||||||
raise ValueError("Authentication service unavailable")
|
raise ValueError("Authentication service unavailable")
|
||||||
|
|
||||||
|
async def get_user_by_email(self, email: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by email address
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.email == email)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_user_by_authentik_id(self, authentik_id: uuid.UUID) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by Authentik UUID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authentik_id: Authentik user UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.authentik_id == authentik_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
||||||
"""
|
"""
|
||||||
Create or update user from OIDC token info
|
Create or update user from OIDC token info
|
||||||
|
|||||||
@@ -36,8 +36,15 @@ class Settings(BaseSettings):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8083
|
port: int = 8083
|
||||||
|
|
||||||
# CORS
|
# CORS - Note: When cors_credentials is True, cannot use "*" for origins
|
||||||
cors_origins: list[str] = ["*"]
|
# Set CORS_ORIGINS env var to override (comma-separated list)
|
||||||
|
cors_origins: list[str] = [
|
||||||
|
"https://home.schweitz.net",
|
||||||
|
"https://tatlock.schweitz.net",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
]
|
||||||
cors_credentials: bool = True
|
cors_credentials: bool = True
|
||||||
cors_methods: list[str] = ["*"]
|
cors_methods: list[str] = ["*"]
|
||||||
cors_headers: list[str] = ["*"]
|
cors_headers: list[str] = ["*"]
|
||||||
|
|||||||
@@ -812,3 +812,205 @@ class TestPhase4Schemas:
|
|||||||
response = ApiKeysListResponse(items=[key], total=1)
|
response = ApiKeysListResponse(items=[key], total=1)
|
||||||
assert len(response.items) == 1
|
assert len(response.items) == 1
|
||||||
assert response.total == 1
|
assert response.total == 1
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET /auth/me Endpoint Tests (NPM Forward Auth)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAuthMeEndpoint:
|
||||||
|
"""Test GET /auth/me endpoint with NPM forward auth."""
|
||||||
|
|
||||||
|
def test_auth_me_in_openapi(self, client):
|
||||||
|
"""Auth me endpoint should be in OpenAPI spec."""
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
spec = response.json()
|
||||||
|
assert "/auth/me" in spec["paths"]
|
||||||
|
assert "get" in spec["paths"]["/auth/me"]
|
||||||
|
|
||||||
|
def test_auth_me_returns_401_without_forward_auth(self, client):
|
||||||
|
"""Should return 401 when accessed without forward auth headers."""
|
||||||
|
response = client.get("/auth/me")
|
||||||
|
# Without NPM forward auth headers, should return 401
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_auth_me_response_schema(self, client):
|
||||||
|
"""Auth me should return AuthSyncResponse schema."""
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
spec = response.json()
|
||||||
|
|
||||||
|
# Check response schema references AuthSyncResponse
|
||||||
|
me_endpoint = spec["paths"]["/auth/me"]["get"]
|
||||||
|
assert "responses" in me_endpoint
|
||||||
|
assert "200" in me_endpoint["responses"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardAuthParsing:
|
||||||
|
"""Test NPM forward auth header parsing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parses_all_headers(self):
|
||||||
|
"""Should parse all X-authentik-* headers."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "john.doe@example.com",
|
||||||
|
"x-authentik-groups": "tatlock-admins, tatlock-media-viewers",
|
||||||
|
"x-authentik-name": "John Doe",
|
||||||
|
"x-authentik-uid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["username"] == "jdoe"
|
||||||
|
assert result["email"] == "john.doe@example.com"
|
||||||
|
assert result["name"] == "John Doe"
|
||||||
|
assert result["uid"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
assert "tatlock-admins" in result["groups"]
|
||||||
|
assert "tatlock-media-viewers" in result["groups"]
|
||||||
|
assert result["auth_method"] == "forward_auth"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_for_internal_access(self):
|
||||||
|
"""Should return None when no forward auth headers (internal access)."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.headers.get.return_value = None
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_401_missing_email(self):
|
||||||
|
"""Should raise 401 when username present but email missing."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_empty_groups(self):
|
||||||
|
"""Should handle empty groups header."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "jdoe@example.com",
|
||||||
|
"x-authentik-groups": "",
|
||||||
|
"x-authentik-name": None,
|
||||||
|
"x-authentik-uid": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["groups"] == [""]
|
||||||
|
assert result["name"] == "jdoe" # Falls back to username
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_strips_whitespace_from_groups(self):
|
||||||
|
"""Should strip whitespace from group names."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "jdoe@example.com",
|
||||||
|
"x-authentik-groups": " group1 , group2 ,group3",
|
||||||
|
"x-authentik-name": "John",
|
||||||
|
"x-authentik-uid": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["groups"] == ["group1", "group2", "group3"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthServiceNewMethods:
|
||||||
|
"""Test new AuthService methods for /auth/me."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email(self):
|
||||||
|
"""Should find user by email."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_user = MagicMock()
|
||||||
|
mock_user.email = "test@example.com"
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_user
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_email("test@example.com")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.email == "test@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email_not_found(self):
|
||||||
|
"""Should return None when user not found."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_email("notfound@example.com")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_authentik_id(self):
|
||||||
|
"""Should find user by Authentik UUID."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
test_id = uuid.uuid4()
|
||||||
|
mock_user = MagicMock()
|
||||||
|
mock_user.authentik_id = test_id
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_user
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_authentik_id(test_id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.authentik_id == test_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_authentik_id_not_found(self):
|
||||||
|
"""Should return None when user not found by Authentik ID."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_authentik_id(uuid.uuid4())
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|||||||
Reference in New Issue
Block a user