Skip to content

Commit 166d0ea

Browse files
author
Aegis Stack
committed
RBAC - 5
1 parent f550ea7 commit 166d0ea

18 files changed

Lines changed: 1032 additions & 40 deletions

File tree

aegis/cli/callbacks.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ def validate_and_resolve_services(
200200
service_name="auth",
201201
level=auth_config.level,
202202
)
203+
if auth_config.engine:
204+
from .interactive import set_database_engine_selection
205+
206+
set_database_engine_selection(auth_config.engine)
203207
typer.echo(f"Auth service: level={auth_config.level}")
204208
except ValueError as e:
205209
typer.secho(f"Invalid auth service syntax: {e}", fg="red", err=True)

aegis/core/auth_service_parser.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
"""
22
Auth service bracket syntax parser.
33
4-
Parses auth[level] syntax where level is one of: basic, rbac.
5-
Default (plain "auth" without brackets): basic.
4+
Parses auth[level, engine] syntax where values are detected by type:
5+
- Levels: basic, rbac, org
6+
- Engines: sqlite, postgres
7+
8+
Order doesn't matter. Defaults: basic, (no engine override).
69
"""
710

811
from dataclasses import dataclass
912

10-
from ..constants import AuthLevels
13+
from ..constants import AuthLevels, StorageBackends
1114

12-
# Valid auth levels
15+
# Valid values for detection
1316
LEVELS = set(AuthLevels.ALL)
17+
ENGINES = {StorageBackends.SQLITE, StorageBackends.POSTGRES}
1418

1519
DEFAULT_LEVEL = AuthLevels.BASIC
1620

@@ -20,17 +24,19 @@ class AuthServiceConfig:
2024
"""Parsed auth service configuration."""
2125

2226
level: str
27+
engine: str | None = None
2328

2429

2530
def parse_auth_service_config(service_string: str) -> AuthServiceConfig:
2631
"""
2732
Parse auth[...] service string into config.
2833
2934
Args:
30-
service_string: Service specification like "auth", "auth[]", or "auth[rbac]"
35+
service_string: Service specification like "auth", "auth[rbac]",
36+
or "auth[org,postgres]"
3137
3238
Returns:
33-
AuthServiceConfig with level
39+
AuthServiceConfig with level and optional engine
3440
3541
Raises:
3642
ValueError: If service string is invalid or has unknown values
@@ -65,15 +71,40 @@ def parse_auth_service_config(service_string: str) -> AuthServiceConfig:
6571
if not bracket_content:
6672
return AuthServiceConfig(level=DEFAULT_LEVEL)
6773

68-
# Single value expected
69-
level = bracket_content.lower()
74+
# Split by comma and categorize
75+
values = [v.strip().lower() for v in bracket_content.split(",") if v.strip()]
76+
77+
found_levels: list[str] = []
78+
found_engines: list[str] = []
79+
80+
for value in values:
81+
if value in LEVELS:
82+
found_levels.append(value)
83+
elif value in ENGINES:
84+
found_engines.append(value)
85+
else:
86+
raise ValueError(
87+
f"Unknown value '{value}' in auth[...] syntax. "
88+
f"Valid levels: {', '.join(sorted(LEVELS))}. "
89+
f"Valid engines: {', '.join(sorted(ENGINES))}."
90+
)
91+
92+
if len(found_levels) > 1:
93+
raise ValueError(
94+
f"Cannot specify multiple levels: {', '.join(found_levels)}. "
95+
f"Choose one of: {', '.join(sorted(LEVELS))}."
96+
)
7097

71-
if level not in LEVELS:
98+
if len(found_engines) > 1:
7299
raise ValueError(
73-
f"Unknown auth level '{level}'. Valid levels: {', '.join(sorted(LEVELS))}."
100+
f"Cannot specify multiple engines: {', '.join(found_engines)}. "
101+
f"Choose one of: {', '.join(sorted(ENGINES))}."
74102
)
75103

76-
return AuthServiceConfig(level=level)
104+
level = found_levels[0] if found_levels else DEFAULT_LEVEL
105+
engine = found_engines[0] if found_engines else None
106+
107+
return AuthServiceConfig(level=level, engine=engine)
77108

78109

79110
def is_auth_service_with_options(service_string: str) -> bool:

aegis/core/migration_generator.py

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,22 @@ class TableSpec:
6363
foreign_keys: list[ForeignKeySpec] = field(default_factory=list)
6464

6565

66+
@dataclass
67+
class AlterTableSpec:
68+
"""Specification for altering an existing table (adding columns)."""
69+
70+
name: str
71+
add_columns: list[ColumnSpec]
72+
73+
6674
@dataclass
6775
class ServiceMigrationSpec:
6876
"""Migration specification for a service."""
6977

7078
service_name: str
7179
tables: list[TableSpec]
7280
description: str
81+
alter_tables: list[AlterTableSpec] = field(default_factory=list)
7382

7483

7584
# ============================================================================
@@ -90,7 +99,6 @@ class ServiceMigrationSpec:
9099
ColumnSpec(
91100
"is_verified", "sa.Boolean()", nullable=False, default="False"
92101
),
93-
ColumnSpec("role", "sa.String()", nullable=False, default="'user'"),
94102
ColumnSpec("hashed_password", "sa.String()", nullable=False),
95103
ColumnSpec("last_login", "sa.DateTime()", nullable=True),
96104
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
@@ -101,6 +109,20 @@ class ServiceMigrationSpec:
101109
],
102110
)
103111

112+
AUTH_RBAC_MIGRATION = ServiceMigrationSpec(
113+
service_name="auth_rbac",
114+
description="RBAC role column for user table",
115+
tables=[],
116+
alter_tables=[
117+
AlterTableSpec(
118+
name="user",
119+
add_columns=[
120+
ColumnSpec("role", "sa.String()", nullable=False, default="'user'"),
121+
],
122+
),
123+
],
124+
)
125+
104126
ORG_MIGRATION = ServiceMigrationSpec(
105127
service_name="auth_org",
106128
description="Organization and membership tables",
@@ -418,6 +440,7 @@ class ServiceMigrationSpec:
418440
# Registry of all service migrations
419441
MIGRATION_SPECS: dict[str, ServiceMigrationSpec] = {
420442
"auth": AUTH_MIGRATION,
443+
"auth_rbac": AUTH_RBAC_MIGRATION,
421444
"auth_org": ORG_MIGRATION,
422445
"ai": AI_MIGRATION,
423446
"ai_voice": VOICE_MIGRATION,
@@ -449,7 +472,7 @@ class ServiceMigrationSpec:
449472
450473
451474
def upgrade() -> None:
452-
"""Create {{ service_name }} service tables."""
475+
"""{{ upgrade_description }}"""
453476
{% for table in tables %}
454477
# Create {{ table.name }} table
455478
op.create_table(
@@ -472,10 +495,22 @@ def upgrade() -> None:
472495
op.create_index(op.f('{{ index.name }}'), '{{ table.name }}', {{ index.columns }}{% if index.unique %}, unique=True{% endif %})
473496
{% endfor %}
474497
498+
{% endfor %}
499+
{% for alter in alter_tables %}
500+
# Alter {{ alter.name }} table
501+
{% for column in alter.add_columns %}
502+
op.add_column('{{ alter.name }}', sa.Column('{{ column.name }}', {{ column.type }}, nullable={{ column.nullable }}{% if column.server_default %}, server_default={{ column.server_default }}{% endif %}))
503+
{% endfor %}
504+
475505
{% endfor %}
476506
477507
def downgrade() -> None:
478-
"""Drop {{ service_name }} service tables."""
508+
"""Reverse {{ service_name }} migration."""
509+
{% for alter in alter_tables|reverse %}
510+
{% for column in alter.add_columns|reverse %}
511+
op.drop_column('{{ alter.name }}', '{{ column.name }}')
512+
{% endfor %}
513+
{% endfor %}
479514
{% for table in tables|reverse %}
480515
{% for index in table.indexes %}
481516
op.drop_index(op.f('{{ index.name }}'), table_name='{{ table.name }}')
@@ -604,14 +639,48 @@ def _render_migration(
604639
}
605640
)
606641

642+
# Prepare alter table data for template
643+
alter_tables_data = []
644+
for alter in spec.alter_tables:
645+
cols = []
646+
for col in alter.add_columns:
647+
# For add_column, server_default needs sa.text() wrapper
648+
server_default = None
649+
if col.default is not None:
650+
server_default = f'sa.text("{col.default}")'
651+
cols.append(
652+
{
653+
"name": col.name,
654+
"type": col.type,
655+
"nullable": col.nullable,
656+
"server_default": server_default,
657+
}
658+
)
659+
alter_tables_data.append(
660+
{
661+
"name": alter.name,
662+
"add_columns": cols,
663+
}
664+
)
665+
666+
# Build upgrade description
667+
if spec.tables and spec.alter_tables:
668+
upgrade_description = f"Create and alter {spec.service_name} service tables."
669+
elif spec.alter_tables:
670+
upgrade_description = f"Add {spec.service_name} columns."
671+
else:
672+
upgrade_description = f"Create {spec.service_name} service tables."
673+
607674
return template.render(
608675
description=spec.description,
609676
service_name=spec.service_name,
677+
upgrade_description=upgrade_description,
610678
revision=revision,
611679
down_revision=down_revision,
612680
down_revision_repr=f"'{down_revision}'" if down_revision else "None",
613681
create_date=datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S.%f"),
614682
tables=tables_data,
683+
alter_tables=alter_tables_data,
615684
)
616685

617686

@@ -693,14 +762,24 @@ def get_services_needing_migrations(context: dict[str, Any]) -> list[str]:
693762
"""
694763
services = []
695764

696-
# Auth service
765+
# Auth service (base user table)
697766
include_auth = context.get("include_auth")
698767
if include_auth == "yes" or include_auth is True:
699768
services.append("auth")
700769

770+
# Auth RBAC columns (rbac or org level)
771+
include_auth_rbac = context.get("include_auth_rbac")
772+
auth_level = context.get("auth_level")
773+
rbac_enabled = (
774+
include_auth_rbac == "yes"
775+
or include_auth_rbac is True
776+
or (isinstance(auth_level, str) and auth_level.lower() in ("rbac", "org"))
777+
)
778+
if (include_auth == "yes" or include_auth is True) and rbac_enabled:
779+
services.append("auth_rbac")
780+
701781
# Auth org tables (only with org-level auth)
702782
include_auth_org = context.get("include_auth_org")
703-
auth_level = context.get("auth_level")
704783
org_enabled = (
705784
include_auth_org == "yes"
706785
or include_auth_org is True

aegis/core/service_resolver.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ..constants import AnswerKeys, ComponentNames, StorageBackends
99
from .ai_service_parser import is_ai_service_with_options, parse_ai_service_config
10+
from .auth_service_parser import is_auth_service_with_options, parse_auth_service_config
1011
from .component_utils import extract_base_component_name, extract_base_service_name
1112
from .dependency_resolver import DependencyResolver
1213
from .services import SERVICES, get_service_dependencies
@@ -59,6 +60,19 @@ def resolve_service_dependencies(
5960
)
6061
service_required_components.add(database_component)
6162

63+
# Handle Auth service with engine override (from bracket syntax)
64+
if base_service == AnswerKeys.SERVICE_AUTH and is_auth_service_with_options(
65+
service
66+
):
67+
auth_config = parse_auth_service_config(service)
68+
if auth_config.engine:
69+
# Replace plain "database" with engine-specific version
70+
service_required_components.discard(ComponentNames.DATABASE)
71+
database_component = (
72+
f"{ComponentNames.DATABASE}[{auth_config.engine}]"
73+
)
74+
service_required_components.add(database_component)
75+
6276
# Convert to list and resolve component-to-component dependencies
6377
component_list = list(service_required_components)
6478
resolved_components = DependencyResolver.resolve_dependencies(component_list)

aegis/i18n/locales/en.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@
112112
"interactive.auth_level_label": "Authentication Level:",
113113
"interactive.auth_select": "What type of authentication?",
114114
"interactive.auth_basic": "Basic - Email/password login",
115-
"interactive.auth_rbac": "With Roles - + role-based access control",
116-
"interactive.auth_org": "With Organizations - + multi-tenant support",
115+
"interactive.auth_rbac": "With Roles - + role-based access control (experimental)",
116+
"interactive.auth_org": "With Organizations - + multi-tenant support (experimental)",
117117
"interactive.auth_selected": "Selected auth level: {level}",
118118
"interactive.auth_db_required": "Database Required:",
119119
"interactive.auth_db_reason": (

aegis/i18n/locales/zh.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@
8888
"interactive.auth_level_label": "认证级别:",
8989
"interactive.auth_select": "需要哪种认证方式?",
9090
"interactive.auth_basic": "基础认证 — 邮箱 + 密码登录",
91-
"interactive.auth_rbac": "角色权限 — 基于角色的访问控制(RBAC)",
92-
"interactive.auth_org": "多租户 — 组织级别的权限隔离",
91+
"interactive.auth_rbac": "角色权限 — 基于角色的访问控制(实验性)",
92+
"interactive.auth_org": "多租户 — 组织级别的权限隔离(实验性)",
9393
"interactive.auth_selected": "认证级别:{level}",
9494
"interactive.auth_db_required": "需要数据库:",
9595
"interactive.auth_db_reason": "认证服务需要数据库来存储用户数据",

aegis/templates/copier-aegis-project/{{ project_slug }}/.env.example.jinja

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ LOGFIRE_TOKEN=
108108
# LOGFIRE_PROJECT_URL=https://logfire.pydantic.dev/myorg/myproject
109109
{%- endif %}
110110

111+
# Authentication (set to false only for local development without login)
112+
AUTH_ENABLED=true
113+
111114
# API Keys and Secrets (add as needed)
112115
# SECRET_KEY=your-super-secret-key-here
113116
# API_KEY=your-api-key-here

aegis/templates/copier-aegis-project/{{ project_slug }}/Makefile.jinja

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ migrate-history: ## Show migration history
188188
@echo "Migration history:"
189189
@docker compose exec webserver uv run alembic -c alembic/alembic.ini history --verbose
190190

191+
migrate-fix: ## Auto-fix schema mismatches after upgrade (safe: only adds, never drops)
192+
@echo "Checking for schema mismatches..."
193+
@docker compose exec webserver uv run python -m app.cli.migrate_fix
194+
@echo "Applying fix migration..."
195+
@docker compose exec webserver uv run alembic -c alembic/alembic.ini upgrade head
196+
@echo "Schema fix complete"
197+
191198
migrate-reset: ## Reset database (WARNING: destructive)
192199
@echo "WARNING: This will destroy all data in the database!"
193200
@read -p "Are you sure? Type 'yes' to continue: " confirm && [ "$$confirm" = "yes" ] || exit 1

0 commit comments

Comments
 (0)