skrift 0.1.0a12__py3-none-any.whl
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.
- skrift/__init__.py +1 -0
- skrift/__main__.py +12 -0
- skrift/admin/__init__.py +11 -0
- skrift/admin/controller.py +452 -0
- skrift/admin/navigation.py +105 -0
- skrift/alembic/env.py +92 -0
- skrift/alembic/script.py.mako +26 -0
- skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
- skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
- skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
- skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
- skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
- skrift/alembic/versions/20260129_add_oauth_accounts.py +141 -0
- skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
- skrift/alembic.ini +77 -0
- skrift/asgi.py +670 -0
- skrift/auth/__init__.py +58 -0
- skrift/auth/guards.py +130 -0
- skrift/auth/roles.py +129 -0
- skrift/auth/services.py +184 -0
- skrift/cli.py +143 -0
- skrift/config.py +259 -0
- skrift/controllers/__init__.py +4 -0
- skrift/controllers/auth.py +595 -0
- skrift/controllers/web.py +67 -0
- skrift/db/__init__.py +3 -0
- skrift/db/base.py +7 -0
- skrift/db/models/__init__.py +7 -0
- skrift/db/models/oauth_account.py +50 -0
- skrift/db/models/page.py +26 -0
- skrift/db/models/role.py +56 -0
- skrift/db/models/setting.py +13 -0
- skrift/db/models/user.py +36 -0
- skrift/db/services/__init__.py +1 -0
- skrift/db/services/oauth_service.py +195 -0
- skrift/db/services/page_service.py +217 -0
- skrift/db/services/setting_service.py +206 -0
- skrift/lib/__init__.py +3 -0
- skrift/lib/exceptions.py +168 -0
- skrift/lib/template.py +108 -0
- skrift/setup/__init__.py +14 -0
- skrift/setup/config_writer.py +213 -0
- skrift/setup/controller.py +888 -0
- skrift/setup/middleware.py +89 -0
- skrift/setup/providers.py +214 -0
- skrift/setup/state.py +315 -0
- skrift/static/css/style.css +1003 -0
- skrift/templates/admin/admin.html +19 -0
- skrift/templates/admin/base.html +24 -0
- skrift/templates/admin/pages/edit.html +32 -0
- skrift/templates/admin/pages/list.html +62 -0
- skrift/templates/admin/settings/site.html +32 -0
- skrift/templates/admin/users/list.html +58 -0
- skrift/templates/admin/users/roles.html +42 -0
- skrift/templates/auth/dummy_login.html +102 -0
- skrift/templates/auth/login.html +139 -0
- skrift/templates/base.html +52 -0
- skrift/templates/error-404.html +19 -0
- skrift/templates/error-500.html +19 -0
- skrift/templates/error.html +19 -0
- skrift/templates/index.html +9 -0
- skrift/templates/page.html +26 -0
- skrift/templates/setup/admin.html +24 -0
- skrift/templates/setup/auth.html +110 -0
- skrift/templates/setup/base.html +407 -0
- skrift/templates/setup/complete.html +17 -0
- skrift/templates/setup/configuring.html +158 -0
- skrift/templates/setup/database.html +125 -0
- skrift/templates/setup/restart.html +28 -0
- skrift/templates/setup/site.html +39 -0
- skrift-0.1.0a12.dist-info/METADATA +235 -0
- skrift-0.1.0a12.dist-info/RECORD +74 -0
- skrift-0.1.0a12.dist-info/WHEEL +4 -0
- skrift-0.1.0a12.dist-info/entry_points.txt +2 -0
skrift/auth/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Authentication and authorization module."""
|
|
2
|
+
|
|
3
|
+
from skrift.auth.guards import (
|
|
4
|
+
ADMINISTRATOR_PERMISSION,
|
|
5
|
+
AndRequirement,
|
|
6
|
+
AuthRequirement,
|
|
7
|
+
OrRequirement,
|
|
8
|
+
Permission,
|
|
9
|
+
Role,
|
|
10
|
+
auth_guard,
|
|
11
|
+
)
|
|
12
|
+
from skrift.auth.roles import (
|
|
13
|
+
ADMIN,
|
|
14
|
+
AUTHOR,
|
|
15
|
+
EDITOR,
|
|
16
|
+
MODERATOR,
|
|
17
|
+
ROLE_DEFINITIONS,
|
|
18
|
+
RoleDefinition,
|
|
19
|
+
create_role,
|
|
20
|
+
get_role_definition,
|
|
21
|
+
register_role,
|
|
22
|
+
)
|
|
23
|
+
from skrift.auth.services import (
|
|
24
|
+
UserPermissions,
|
|
25
|
+
assign_role_to_user,
|
|
26
|
+
get_user_permissions,
|
|
27
|
+
invalidate_user_permissions_cache,
|
|
28
|
+
remove_role_from_user,
|
|
29
|
+
sync_roles_to_database,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Guards
|
|
34
|
+
"ADMINISTRATOR_PERMISSION",
|
|
35
|
+
"AndRequirement",
|
|
36
|
+
"AuthRequirement",
|
|
37
|
+
"OrRequirement",
|
|
38
|
+
"Permission",
|
|
39
|
+
"Role",
|
|
40
|
+
"auth_guard",
|
|
41
|
+
# Roles
|
|
42
|
+
"ADMIN",
|
|
43
|
+
"AUTHOR",
|
|
44
|
+
"EDITOR",
|
|
45
|
+
"MODERATOR",
|
|
46
|
+
"ROLE_DEFINITIONS",
|
|
47
|
+
"RoleDefinition",
|
|
48
|
+
"create_role",
|
|
49
|
+
"get_role_definition",
|
|
50
|
+
"register_role",
|
|
51
|
+
# Services
|
|
52
|
+
"UserPermissions",
|
|
53
|
+
"assign_role_to_user",
|
|
54
|
+
"get_user_permissions",
|
|
55
|
+
"invalidate_user_permissions_cache",
|
|
56
|
+
"remove_role_from_user",
|
|
57
|
+
"sync_roles_to_database",
|
|
58
|
+
]
|
skrift/auth/guards.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Permission and Role guards for Litestar routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from litestar.connection import ASGIConnection
|
|
9
|
+
from litestar.exceptions import NotAuthorizedException
|
|
10
|
+
from litestar.handlers import BaseRouteHandler
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from skrift.auth.services import UserPermissions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ADMINISTRATOR_PERMISSION = "administrator"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthRequirement(ABC):
|
|
20
|
+
"""Base class for authorization requirements with operator overloading."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def check(self, permissions: "UserPermissions") -> bool:
|
|
24
|
+
"""Check if the requirement is satisfied."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def __or__(self, other: "AuthRequirement") -> "OrRequirement":
|
|
28
|
+
"""Combine requirements with OR logic."""
|
|
29
|
+
return OrRequirement(self, other)
|
|
30
|
+
|
|
31
|
+
def __and__(self, other: "AuthRequirement") -> "AndRequirement":
|
|
32
|
+
"""Combine requirements with AND logic."""
|
|
33
|
+
return AndRequirement(self, other)
|
|
34
|
+
|
|
35
|
+
def __call__(
|
|
36
|
+
self, connection: ASGIConnection, _: BaseRouteHandler
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Guard function for use with Litestar guards parameter.
|
|
39
|
+
|
|
40
|
+
This is a synchronous wrapper - actual checking happens in the async guard.
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OrRequirement(AuthRequirement):
|
|
46
|
+
"""Combines two requirements with OR logic."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, left: AuthRequirement, right: AuthRequirement):
|
|
49
|
+
self.left = left
|
|
50
|
+
self.right = right
|
|
51
|
+
|
|
52
|
+
async def check(self, permissions: "UserPermissions") -> bool:
|
|
53
|
+
"""Return True if either requirement is satisfied."""
|
|
54
|
+
return await self.left.check(permissions) or await self.right.check(permissions)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AndRequirement(AuthRequirement):
|
|
58
|
+
"""Combines two requirements with AND logic."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, left: AuthRequirement, right: AuthRequirement):
|
|
61
|
+
self.left = left
|
|
62
|
+
self.right = right
|
|
63
|
+
|
|
64
|
+
async def check(self, permissions: "UserPermissions") -> bool:
|
|
65
|
+
"""Return True if both requirements are satisfied."""
|
|
66
|
+
return await self.left.check(permissions) and await self.right.check(permissions)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Permission(AuthRequirement):
|
|
70
|
+
"""Permission requirement for route guards."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, permission: str):
|
|
73
|
+
self.permission = permission
|
|
74
|
+
|
|
75
|
+
async def check(self, permissions: "UserPermissions") -> bool:
|
|
76
|
+
"""Check if user has the required permission or administrator permission."""
|
|
77
|
+
# Administrator permission bypasses all checks
|
|
78
|
+
if ADMINISTRATOR_PERMISSION in permissions.permissions:
|
|
79
|
+
return True
|
|
80
|
+
return self.permission in permissions.permissions
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Role(AuthRequirement):
|
|
84
|
+
"""Role requirement for route guards."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, role: str):
|
|
87
|
+
self.role = role
|
|
88
|
+
|
|
89
|
+
async def check(self, permissions: "UserPermissions") -> bool:
|
|
90
|
+
"""Check if user has the required role or administrator permission."""
|
|
91
|
+
# Administrator permission bypasses all checks
|
|
92
|
+
if ADMINISTRATOR_PERMISSION in permissions.permissions:
|
|
93
|
+
return True
|
|
94
|
+
return self.role in permissions.roles
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def auth_guard(
|
|
98
|
+
connection: ASGIConnection, route_handler: BaseRouteHandler
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Litestar guard that checks authentication and authorization requirements.
|
|
101
|
+
|
|
102
|
+
This guard checks the guards parameter of route handlers for AuthRequirement
|
|
103
|
+
instances and validates them against the user's permissions and roles.
|
|
104
|
+
"""
|
|
105
|
+
from skrift.auth.services import get_user_permissions
|
|
106
|
+
|
|
107
|
+
# Get user_id from session
|
|
108
|
+
user_id = connection.session.get("user_id") if connection.session else None
|
|
109
|
+
|
|
110
|
+
if not user_id:
|
|
111
|
+
raise NotAuthorizedException("Authentication required")
|
|
112
|
+
|
|
113
|
+
# Get the guards from the route handler
|
|
114
|
+
guards = route_handler.guards or []
|
|
115
|
+
|
|
116
|
+
# Find AuthRequirement guards
|
|
117
|
+
auth_requirements = [g for g in guards if isinstance(g, AuthRequirement)]
|
|
118
|
+
|
|
119
|
+
if not auth_requirements:
|
|
120
|
+
return # No auth requirements, just needs to be logged in
|
|
121
|
+
|
|
122
|
+
# Get user's permissions and roles
|
|
123
|
+
session_maker = connection.app.state.session_maker_class
|
|
124
|
+
async with session_maker() as session:
|
|
125
|
+
permissions = await get_user_permissions(session, user_id)
|
|
126
|
+
|
|
127
|
+
# Check all requirements
|
|
128
|
+
for requirement in auth_requirements:
|
|
129
|
+
if not await requirement.check(permissions):
|
|
130
|
+
raise NotAuthorizedException("Insufficient permissions")
|
skrift/auth/roles.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Role definitions for the application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RoleDefinition:
|
|
10
|
+
"""Definition of a role with its permissions."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
permissions: set[str] = field(default_factory=set)
|
|
14
|
+
display_name: str | None = None
|
|
15
|
+
description: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_role(
|
|
19
|
+
name: str,
|
|
20
|
+
*permissions: str,
|
|
21
|
+
display_name: str | None = None,
|
|
22
|
+
description: str | None = None,
|
|
23
|
+
) -> RoleDefinition:
|
|
24
|
+
"""Create a role definition with the given permissions.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: The unique identifier for the role
|
|
28
|
+
*permissions: Permission strings granted by this role
|
|
29
|
+
display_name: Human-readable name for the role
|
|
30
|
+
description: Description of the role's purpose
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A RoleDefinition instance
|
|
34
|
+
"""
|
|
35
|
+
return RoleDefinition(
|
|
36
|
+
name=name,
|
|
37
|
+
permissions=set(permissions),
|
|
38
|
+
display_name=display_name or name.title(),
|
|
39
|
+
description=description,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Default role definitions
|
|
44
|
+
# The "administrator" permission is special - it bypasses all permission checks
|
|
45
|
+
|
|
46
|
+
ADMIN = create_role(
|
|
47
|
+
"admin",
|
|
48
|
+
"administrator",
|
|
49
|
+
"manage-users",
|
|
50
|
+
"manage-pages",
|
|
51
|
+
"modify-site",
|
|
52
|
+
display_name="Administrator",
|
|
53
|
+
description="Full system access with all permissions",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
AUTHOR = create_role(
|
|
57
|
+
"author",
|
|
58
|
+
"view-drafts",
|
|
59
|
+
display_name="Author",
|
|
60
|
+
description="Can view draft content",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
EDITOR = create_role(
|
|
64
|
+
"editor",
|
|
65
|
+
"view-drafts",
|
|
66
|
+
"manage-pages",
|
|
67
|
+
display_name="Editor",
|
|
68
|
+
description="Can manage pages and view drafts",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
MODERATOR = create_role(
|
|
72
|
+
"moderator",
|
|
73
|
+
"view-drafts",
|
|
74
|
+
display_name="Moderator",
|
|
75
|
+
description="Can moderate content",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Registry of all role definitions
|
|
79
|
+
ROLE_DEFINITIONS: dict[str, RoleDefinition] = {
|
|
80
|
+
role.name: role for role in [ADMIN, AUTHOR, EDITOR, MODERATOR]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_role_definition(name: str) -> RoleDefinition | None:
|
|
85
|
+
"""Get a role definition by name."""
|
|
86
|
+
return ROLE_DEFINITIONS.get(name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def register_role(
|
|
90
|
+
name: str,
|
|
91
|
+
*permissions: str,
|
|
92
|
+
display_name: str | None = None,
|
|
93
|
+
description: str | None = None,
|
|
94
|
+
) -> RoleDefinition:
|
|
95
|
+
"""Register a custom role definition.
|
|
96
|
+
|
|
97
|
+
This allows applications to add custom roles beyond the defaults.
|
|
98
|
+
Call this during application startup (e.g., in a custom controller module
|
|
99
|
+
or app initialization) before the database sync occurs.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
name: The unique identifier for the role
|
|
103
|
+
*permissions: Permission strings granted by this role
|
|
104
|
+
display_name: Human-readable name for the role
|
|
105
|
+
description: Description of the role's purpose
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The registered RoleDefinition instance
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
from skrift.auth.roles import register_role
|
|
112
|
+
|
|
113
|
+
# Register a custom role with permissions
|
|
114
|
+
register_role(
|
|
115
|
+
"support",
|
|
116
|
+
"view-tickets",
|
|
117
|
+
"respond-tickets",
|
|
118
|
+
display_name="Support Agent",
|
|
119
|
+
description="Can view and respond to support tickets",
|
|
120
|
+
)
|
|
121
|
+
"""
|
|
122
|
+
role = create_role(
|
|
123
|
+
name,
|
|
124
|
+
*permissions,
|
|
125
|
+
display_name=display_name,
|
|
126
|
+
description=description,
|
|
127
|
+
)
|
|
128
|
+
ROLE_DEFINITIONS[role.name] = role
|
|
129
|
+
return role
|
skrift/auth/services.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Authentication and authorization services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import delete, select
|
|
11
|
+
from sqlalchemy.orm import selectinload
|
|
12
|
+
|
|
13
|
+
from skrift.db.models.role import Role, RolePermission
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Cache for user permissions with TTL
|
|
20
|
+
_permission_cache: dict[str, tuple[datetime, "UserPermissions"]] = {}
|
|
21
|
+
CACHE_TTL = timedelta(minutes=5)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class UserPermissions:
|
|
26
|
+
"""Container for a user's permissions and roles."""
|
|
27
|
+
|
|
28
|
+
user_id: str
|
|
29
|
+
roles: set[str] = field(default_factory=set)
|
|
30
|
+
permissions: set[str] = field(default_factory=set)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def get_user_permissions(
|
|
34
|
+
session: "AsyncSession", user_id: str | UUID
|
|
35
|
+
) -> UserPermissions:
|
|
36
|
+
"""Get all permissions and roles for a user.
|
|
37
|
+
|
|
38
|
+
Results are cached with a TTL for performance.
|
|
39
|
+
"""
|
|
40
|
+
user_id_str = str(user_id)
|
|
41
|
+
|
|
42
|
+
# Check cache
|
|
43
|
+
if user_id_str in _permission_cache:
|
|
44
|
+
cached_time, cached_perms = _permission_cache[user_id_str]
|
|
45
|
+
if datetime.now() - cached_time < CACHE_TTL:
|
|
46
|
+
return cached_perms
|
|
47
|
+
|
|
48
|
+
# Query user with roles and permissions
|
|
49
|
+
from skrift.db.models.user import User
|
|
50
|
+
|
|
51
|
+
result = await session.execute(
|
|
52
|
+
select(User)
|
|
53
|
+
.where(User.id == (UUID(user_id_str) if isinstance(user_id, str) else user_id))
|
|
54
|
+
.options(selectinload(User.roles).selectinload(Role.permissions))
|
|
55
|
+
)
|
|
56
|
+
user = result.scalar_one_or_none()
|
|
57
|
+
|
|
58
|
+
permissions = UserPermissions(user_id=user_id_str)
|
|
59
|
+
|
|
60
|
+
if user:
|
|
61
|
+
for role in user.roles:
|
|
62
|
+
permissions.roles.add(role.name)
|
|
63
|
+
for role_perm in role.permissions:
|
|
64
|
+
permissions.permissions.add(role_perm.permission)
|
|
65
|
+
|
|
66
|
+
# Update cache
|
|
67
|
+
_permission_cache[user_id_str] = (datetime.now(), permissions)
|
|
68
|
+
|
|
69
|
+
return permissions
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def invalidate_user_permissions_cache(user_id: str | UUID | None = None) -> None:
|
|
73
|
+
"""Invalidate cached permissions for a user or all users.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
user_id: Specific user to invalidate, or None to clear all cache
|
|
77
|
+
"""
|
|
78
|
+
if user_id is None:
|
|
79
|
+
_permission_cache.clear()
|
|
80
|
+
else:
|
|
81
|
+
_permission_cache.pop(str(user_id), None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def assign_role_to_user(
|
|
85
|
+
session: "AsyncSession", user_id: str | UUID, role_name: str
|
|
86
|
+
) -> bool:
|
|
87
|
+
"""Assign a role to a user.
|
|
88
|
+
|
|
89
|
+
Returns True if the role was assigned, False if role not found.
|
|
90
|
+
"""
|
|
91
|
+
from skrift.db.models.user import User
|
|
92
|
+
|
|
93
|
+
user_id_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
|
|
94
|
+
|
|
95
|
+
# Get user and role
|
|
96
|
+
user_result = await session.execute(
|
|
97
|
+
select(User).where(User.id == user_id_uuid).options(selectinload(User.roles))
|
|
98
|
+
)
|
|
99
|
+
user = user_result.scalar_one_or_none()
|
|
100
|
+
|
|
101
|
+
role_result = await session.execute(select(Role).where(Role.name == role_name))
|
|
102
|
+
role = role_result.scalar_one_or_none()
|
|
103
|
+
|
|
104
|
+
if not user or not role:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Check if already assigned
|
|
108
|
+
if role not in user.roles:
|
|
109
|
+
user.roles.append(role)
|
|
110
|
+
await session.commit()
|
|
111
|
+
invalidate_user_permissions_cache(user_id)
|
|
112
|
+
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def remove_role_from_user(
|
|
117
|
+
session: "AsyncSession", user_id: str | UUID, role_name: str
|
|
118
|
+
) -> bool:
|
|
119
|
+
"""Remove a role from a user.
|
|
120
|
+
|
|
121
|
+
Returns True if the role was removed, False if not found.
|
|
122
|
+
"""
|
|
123
|
+
from skrift.db.models.user import User
|
|
124
|
+
|
|
125
|
+
user_id_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
|
|
126
|
+
|
|
127
|
+
# Get user with roles
|
|
128
|
+
user_result = await session.execute(
|
|
129
|
+
select(User).where(User.id == user_id_uuid).options(selectinload(User.roles))
|
|
130
|
+
)
|
|
131
|
+
user = user_result.scalar_one_or_none()
|
|
132
|
+
|
|
133
|
+
if not user:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
# Find and remove the role
|
|
137
|
+
for role in user.roles:
|
|
138
|
+
if role.name == role_name:
|
|
139
|
+
user.roles.remove(role)
|
|
140
|
+
await session.commit()
|
|
141
|
+
invalidate_user_permissions_cache(user_id)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def sync_roles_to_database(session: "AsyncSession") -> None:
|
|
148
|
+
"""Sync role definitions from code to the database.
|
|
149
|
+
|
|
150
|
+
This creates or updates roles based on the definitions in roles.py.
|
|
151
|
+
"""
|
|
152
|
+
from skrift.auth.roles import ROLE_DEFINITIONS
|
|
153
|
+
|
|
154
|
+
for role_def in ROLE_DEFINITIONS.values():
|
|
155
|
+
# Check if role exists
|
|
156
|
+
result = await session.execute(select(Role).where(Role.name == role_def.name))
|
|
157
|
+
role = result.scalar_one_or_none()
|
|
158
|
+
|
|
159
|
+
if role:
|
|
160
|
+
# Update existing role
|
|
161
|
+
role.display_name = role_def.display_name
|
|
162
|
+
role.description = role_def.description
|
|
163
|
+
|
|
164
|
+
# Remove old permissions
|
|
165
|
+
await session.execute(
|
|
166
|
+
delete(RolePermission).where(RolePermission.role_id == role.id)
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
# Create new role
|
|
170
|
+
role = Role(
|
|
171
|
+
name=role_def.name,
|
|
172
|
+
display_name=role_def.display_name,
|
|
173
|
+
description=role_def.description,
|
|
174
|
+
)
|
|
175
|
+
session.add(role)
|
|
176
|
+
await session.flush() # Get the role ID
|
|
177
|
+
|
|
178
|
+
# Add permissions
|
|
179
|
+
for permission in role_def.permissions:
|
|
180
|
+
role_permission = RolePermission(role_id=role.id, permission=permission)
|
|
181
|
+
session.add(role_permission)
|
|
182
|
+
|
|
183
|
+
await session.commit()
|
|
184
|
+
invalidate_user_permissions_cache()
|
skrift/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""CLI commands for Skrift."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(package_name="skrift")
|
|
15
|
+
def cli():
|
|
16
|
+
"""Skrift - A lightweight async Python CMS."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cli.command()
|
|
21
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to")
|
|
22
|
+
@click.option("--port", default=8080, type=int, help="Port to bind to")
|
|
23
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload for development")
|
|
24
|
+
@click.option("--workers", default=1, type=int, help="Number of worker processes")
|
|
25
|
+
@click.option(
|
|
26
|
+
"--log-level",
|
|
27
|
+
default="info",
|
|
28
|
+
type=click.Choice(["debug", "info", "warning", "error"]),
|
|
29
|
+
help="Logging level",
|
|
30
|
+
)
|
|
31
|
+
def serve(host, port, reload, workers, log_level):
|
|
32
|
+
"""Run the Skrift server."""
|
|
33
|
+
import uvicorn
|
|
34
|
+
|
|
35
|
+
uvicorn.run(
|
|
36
|
+
"skrift.asgi:app",
|
|
37
|
+
host=host,
|
|
38
|
+
port=port,
|
|
39
|
+
reload=reload,
|
|
40
|
+
workers=workers if not reload else 1,
|
|
41
|
+
log_level=log_level,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cli.command()
|
|
46
|
+
@click.option(
|
|
47
|
+
"--write",
|
|
48
|
+
type=click.Path(),
|
|
49
|
+
default=None,
|
|
50
|
+
help="Write SECRET_KEY to a .env file",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--format",
|
|
54
|
+
"fmt",
|
|
55
|
+
default="urlsafe",
|
|
56
|
+
type=click.Choice(["urlsafe", "hex", "base64"]),
|
|
57
|
+
help="Output format for the secret key",
|
|
58
|
+
)
|
|
59
|
+
@click.option("--length", default=32, type=int, help="Number of random bytes")
|
|
60
|
+
def secret(write, fmt, length):
|
|
61
|
+
"""Generate a secure secret key."""
|
|
62
|
+
# Generate key based on format
|
|
63
|
+
if fmt == "urlsafe":
|
|
64
|
+
key = secrets.token_urlsafe(length)
|
|
65
|
+
elif fmt == "hex":
|
|
66
|
+
key = secrets.token_hex(length)
|
|
67
|
+
else: # base64
|
|
68
|
+
key = base64.b64encode(secrets.token_bytes(length)).decode("ascii")
|
|
69
|
+
|
|
70
|
+
if write:
|
|
71
|
+
env_path = Path(write)
|
|
72
|
+
env_content = ""
|
|
73
|
+
|
|
74
|
+
# Read existing content if file exists
|
|
75
|
+
if env_path.exists():
|
|
76
|
+
env_content = env_path.read_text()
|
|
77
|
+
|
|
78
|
+
# Update or add SECRET_KEY
|
|
79
|
+
secret_key_pattern = re.compile(r"^SECRET_KEY=.*$", re.MULTILINE)
|
|
80
|
+
new_line = f"SECRET_KEY={key}"
|
|
81
|
+
|
|
82
|
+
if secret_key_pattern.search(env_content):
|
|
83
|
+
# Replace existing SECRET_KEY
|
|
84
|
+
env_content = secret_key_pattern.sub(new_line, env_content)
|
|
85
|
+
else:
|
|
86
|
+
# Add SECRET_KEY at the end
|
|
87
|
+
if env_content and not env_content.endswith("\n"):
|
|
88
|
+
env_content += "\n"
|
|
89
|
+
env_content += new_line + "\n"
|
|
90
|
+
|
|
91
|
+
env_path.write_text(env_content)
|
|
92
|
+
click.echo(f"SECRET_KEY written to {env_path}")
|
|
93
|
+
else:
|
|
94
|
+
click.echo(key)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cli.command(
|
|
98
|
+
context_settings=dict(
|
|
99
|
+
ignore_unknown_options=True,
|
|
100
|
+
allow_extra_args=True,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
@click.pass_context
|
|
104
|
+
def db(ctx):
|
|
105
|
+
"""Run database migrations via Alembic.
|
|
106
|
+
|
|
107
|
+
\b
|
|
108
|
+
Examples:
|
|
109
|
+
skrift db upgrade head # Apply all migrations
|
|
110
|
+
skrift db downgrade -1 # Rollback one migration
|
|
111
|
+
skrift db current # Show current revision
|
|
112
|
+
skrift db history # Show migration history
|
|
113
|
+
skrift db revision -m "description" --autogenerate # Create new migration
|
|
114
|
+
"""
|
|
115
|
+
from alembic.config import main as alembic_main
|
|
116
|
+
|
|
117
|
+
# Always run from the project root (where app.yaml and .env are)
|
|
118
|
+
# This ensures database paths like ./app.db resolve correctly
|
|
119
|
+
project_root = Path.cwd()
|
|
120
|
+
if not (project_root / "app.yaml").exists():
|
|
121
|
+
# If not in project root, try parent directory
|
|
122
|
+
project_root = Path(__file__).parent.parent
|
|
123
|
+
os.chdir(project_root)
|
|
124
|
+
|
|
125
|
+
# Find alembic.ini - check project root first, then skrift package directory
|
|
126
|
+
alembic_ini = project_root / "alembic.ini"
|
|
127
|
+
if not alembic_ini.exists():
|
|
128
|
+
skrift_dir = Path(__file__).parent
|
|
129
|
+
alembic_ini = skrift_dir / "alembic.ini"
|
|
130
|
+
|
|
131
|
+
if not alembic_ini.exists():
|
|
132
|
+
click.echo("Error: Could not find alembic.ini", err=True)
|
|
133
|
+
click.echo("Make sure you're running from the project root directory.", err=True)
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
# Build argv for alembic: ['-c', '/path/to/alembic.ini', ...extra_args]
|
|
137
|
+
alembic_argv = ["-c", str(alembic_ini)] + ctx.args
|
|
138
|
+
|
|
139
|
+
sys.exit(alembic_main(alembic_argv))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
cli()
|