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
1 change: 1 addition & 0 deletions aegis/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ class AnswerKeys:
AUTH_LEVEL = "auth_level"
AUTH_RBAC = "include_auth_rbac"
AUTH_ORG = "include_auth_org"
AUTH_OAUTH = "include_oauth"
AI_VOICE = "ai_voice"
OLLAMA_MODE = "ollama_mode"
PROJECT_SLUG = "project_slug"
Expand Down
33 changes: 29 additions & 4 deletions aegis/core/auth_service_parser.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""
Auth service bracket syntax parser.

Parses auth[level, engine] syntax where values are detected by type:
Parses auth[level, engine, modifier] syntax where values are detected
by type:

- Levels: basic, rbac, org
- Engines: sqlite, postgres
- Modifiers: oauth (boolean toggle for social login)

Order doesn't matter. Defaults: basic, (no engine override).
Order doesn't matter. Defaults: basic, (no engine override), no
modifiers.
"""

from dataclasses import dataclass
Expand All @@ -15,6 +19,10 @@
# Valid values for detection
LEVELS = set(AuthLevels.ALL)
ENGINES = {StorageBackends.SQLITE, StorageBackends.POSTGRES}
# Modifiers are bracket tokens that flip a boolean on the config — they
# coexist with the level + engine slots rather than replacing them. Add
# new modifiers here when adding bracket-toggleable auth features.
MODIFIERS = {"oauth"}

DEFAULT_LEVEL = AuthLevels.BASIC

Expand All @@ -25,6 +33,7 @@ class AuthServiceConfig:

level: str
engine: str | None = None
oauth: bool = False


def parse_auth_service_config(service_string: str) -> AuthServiceConfig:
Expand Down Expand Up @@ -76,17 +85,21 @@ def parse_auth_service_config(service_string: str) -> AuthServiceConfig:

found_levels: list[str] = []
found_engines: list[str] = []
found_modifiers: list[str] = []

for value in values:
if value in LEVELS:
found_levels.append(value)
elif value in ENGINES:
found_engines.append(value)
elif value in MODIFIERS:
found_modifiers.append(value)
else:
raise ValueError(
f"Unknown value '{value}' in auth[...] syntax. "
f"Valid levels: {', '.join(sorted(LEVELS))}. "
f"Valid engines: {', '.join(sorted(ENGINES))}."
f"Valid engines: {', '.join(sorted(ENGINES))}. "
f"Valid modifiers: {', '.join(sorted(MODIFIERS))}."
)

if len(found_levels) > 1:
Expand All @@ -101,10 +114,22 @@ def parse_auth_service_config(service_string: str) -> AuthServiceConfig:
f"Choose one of: {', '.join(sorted(ENGINES))}."
)

# Modifiers are flags — repeating one is a typo, not a different
# selection, so reject it the same way duplicate levels/engines are
# rejected.
if len(found_modifiers) != len(set(found_modifiers)):
duplicates = sorted(
{m for m in found_modifiers if found_modifiers.count(m) > 1}
)
raise ValueError(
f"Duplicate modifier(s) in auth[...] syntax: {', '.join(duplicates)}."
)

level = found_levels[0] if found_levels else DEFAULT_LEVEL
engine = found_engines[0] if found_engines else None
oauth = "oauth" in found_modifiers

return AuthServiceConfig(level=level, engine=engine)
return AuthServiceConfig(level=level, engine=engine, oauth=oauth)


def is_auth_service_with_options(service_string: str) -> bool:
Expand Down
15 changes: 13 additions & 2 deletions aegis/core/copier_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ def generate_with_copier(
AnswerKeys.AUTH_LEVEL: template_context.get(AnswerKeys.AUTH_LEVEL, "basic"),
AnswerKeys.AUTH_RBAC: template_context.get(AnswerKeys.AUTH_RBAC, "no") == "yes",
AnswerKeys.AUTH_ORG: template_context.get(AnswerKeys.AUTH_ORG, "no") == "yes",
AnswerKeys.AUTH_OAUTH: template_context.get(AnswerKeys.AUTH_OAUTH, "no")
== "yes",
Comment thread
lbedner marked this conversation as resolved.
AnswerKeys.AI: template_context.get(AnswerKeys.AI, "no") == "yes",
AnswerKeys.COMMS: template_context.get(AnswerKeys.COMMS, "no") == "yes",
AnswerKeys.AI_FRAMEWORK: template_context.get(
Expand Down Expand Up @@ -232,8 +234,17 @@ def generate_with_copier(
if template_version:
answers["_template_version"] = template_version

# Persist conditional choice fields that Copier omits
for key in (AnswerKeys.WORKER_BACKEND, AnswerKeys.SCHEDULER_BACKEND):
# Persist conditional choice fields that Copier omits.
# ``include_oauth`` is gated on ``include_auth`` (``when:`` in
# copier.yml) so Copier strips it from the answers file even
# when explicitly set in ``data``. Without this patch,
# ``aegis update`` and any other tooling reading the answers
# file can't tell whether OAuth was selected at init time.
for key in (
AnswerKeys.WORKER_BACKEND,
AnswerKeys.SCHEDULER_BACKEND,
AnswerKeys.AUTH_OAUTH,
):
if key in copier_data:
answers[key] = copier_data[key]

Expand Down
58 changes: 56 additions & 2 deletions aegis/core/migration_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ class ForeignKeySpec:
ref_columns: list[str]


@dataclass
class CheckConstraintSpec:
"""Specification for a CHECK constraint on a table.

Used to enforce enum-style allowed values without a native PG enum type.
Rendered into ``op.create_table`` so it works on both SQLite and Postgres.
"""

name: str
sqltext: str # e.g. "origin IN ('collector', 'user')"


@dataclass
class TableSpec:
"""Specification for a database table."""
Expand All @@ -61,6 +73,7 @@ class TableSpec:
columns: list[ColumnSpec]
indexes: list[IndexSpec] = field(default_factory=list)
foreign_keys: list[ForeignKeySpec] = field(default_factory=list)
check_constraints: list[CheckConstraintSpec] = field(default_factory=list)


@dataclass
Expand Down Expand Up @@ -141,6 +154,12 @@ class ServiceMigrationSpec:
foreign_keys=[
ForeignKeySpec(["user_id"], "user", ["id"]),
],
check_constraints=[
CheckConstraintSpec(
name="ck_user_oauth_identity_provider",
sqltext="provider IN ('github', 'google')",
),
],
),
],
)
Expand Down Expand Up @@ -662,6 +681,12 @@ class ServiceMigrationSpec:
IndexSpec("ix_insight_event_origin", ["origin"]),
IndexSpec("ix_insight_event_origin_date", ["origin", "date"]),
],
check_constraints=[
CheckConstraintSpec(
name="ck_insight_event_origin",
sqltext="origin IN ('collector', 'user')",
),
],
),
],
)
Expand Down Expand Up @@ -710,6 +735,27 @@ class ServiceMigrationSpec:
foreign_keys=[
ForeignKeySpec(["user_id"], "user", ["id"]),
],
check_constraints=[
CheckConstraintSpec(
name="ck_insight_goal_kind",
sqltext="kind IN ('absolute', 'delta', 'rate')",
),
CheckConstraintSpec(
name="ck_insight_goal_status",
sqltext="status IN ('active', 'achieved', 'abandoned')",
),
CheckConstraintSpec(
name="ck_insight_goal_metric_key",
sqltext=(
"metric_key IN ("
"'github.stars', 'github.clones', 'github.unique_cloners', "
"'github.views', 'github.unique_visitors', 'github.forks', "
"'github.releases', 'pypi.downloads', 'docs.visitors', "
"'docs.pageviews'"
")"
),
),
],
),
],
alter_tables=[
Expand Down Expand Up @@ -956,11 +1002,15 @@ def upgrade() -> None:
sa.Column('{{ column.name }}', {{ column.type }}, nullable={{ column.nullable }}{{ pk_attr }}{{ default_attr }}),
{% endfor %}
{% if table.primary_keys %}
sa.PrimaryKeyConstraint({% for pk in table.primary_keys %}'{{ pk }}'{% if not loop.last %}, {% endif %}{% endfor %}){% if table.foreign_keys %},{% endif %}
sa.PrimaryKeyConstraint({% for pk in table.primary_keys %}'{{ pk }}'{% if not loop.last %}, {% endif %}{% endfor %}){% if table.foreign_keys or table.check_constraints %},{% endif %}

{% endif %}
{% for fk in table.foreign_keys %}
sa.ForeignKeyConstraint({{ fk.columns }}, ['{{ fk.ref_table }}.{{ fk.ref_columns[0] }}']){% if not loop.last %},{% endif %}
sa.ForeignKeyConstraint({{ fk.columns }}, ['{{ fk.ref_table }}.{{ fk.ref_columns[0] }}']){% if not loop.last or table.check_constraints %},{% endif %}

{% endfor %}
{% for chk in table.check_constraints %}
sa.CheckConstraint("{{ chk.sqltext }}", name='{{ chk.name }}'){% if not loop.last %},{% endif %}

{% endfor %}
)
Comment thread
lbedner marked this conversation as resolved.
Expand Down Expand Up @@ -1118,6 +1168,10 @@ def _render_migration(
}
for fk in table.foreign_keys
],
"check_constraints": [
{"name": chk.name, "sqltext": chk.sqltext}
for chk in table.check_constraints
],
"primary_keys": primary_keys,
}
)
Expand Down
11 changes: 11 additions & 0 deletions aegis/core/post_gen_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,17 @@ def _rename_backend_files(suffix: str) -> set[str]:
remove_file(project_path, "tests/services/test_goal_service.py")
# Note: alembic removal is handled below based on whether ANY service needs migrations

# Remove OAuth (social login) files when not selected. Auth-only
# projects without OAuth still have ``OAuthProvider`` /
# ``UserOAuthIdentity`` SQLModels in ``app/models/user.py`` (the
# tables ship with the auth migration unconditionally), but the
# routes, middleware, settings, and tests are scoped here.
if not is_enabled(AnswerKeys.AUTH_OAUTH):
remove_file(project_path, "app/components/backend/api/auth/oauth.py")
remove_file(project_path, "app/components/backend/middleware/session.py")
remove_file(project_path, "tests/api/test_oauth_endpoints.py")
remove_file(project_path, "tests/services/test_oauth_user_service.py")

# Remove auth org files if org level not selected (but auth is enabled)
if is_enabled(AnswerKeys.AUTH) and not is_enabled(AnswerKeys.AUTH_ORG):
remove_file(project_path, "app/models/org.py")
Expand Down
14 changes: 13 additions & 1 deletion aegis/core/template_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,13 @@ def __init__(
user_specified_ai_backend = True
break

# Extract auth level from auth[level] format in services
# OAuth social login (GitHub + Google) — opt-in via the
# ``auth[oauth]`` modifier in the bracket syntax (composes with
# ``auth[rbac,oauth]`` etc.). Defaults off so projects that
# don't ask for it don't pay for the extra routes and deps.
self.include_oauth: bool = False

# Extract auth level (and oauth modifier) from auth[...] format in services
self.auth_level = AuthLevels.BASIC # Default to basic
self._user_specified_auth_level = False
for service in self.selected_services:
Expand All @@ -132,6 +138,7 @@ def __init__(
auth_config = parse_auth_service_config(service)
self.auth_level = auth_config.level
self._user_specified_auth_level = True
self.include_oauth = auth_config.oauth
break

# Extract insights sources from insights[sources] format in services
Expand Down Expand Up @@ -237,6 +244,11 @@ def get_template_context(self) -> dict[str, Any]:
if auth_level in (AuthLevels.RBAC, AuthLevels.ORG)
else "no",
AnswerKeys.AUTH_ORG: "yes" if auth_level == AuthLevels.ORG else "no",
# OAuth social login (GitHub + Google) — opt-in via the
# ``auth[oauth]`` modifier in the bracket syntax (composes
# with ``auth[rbac,oauth]`` etc.). Defaults off so existing
# flows aren't surprised by extra routes and dependencies.
AnswerKeys.AUTH_OAUTH: "yes" if self.include_oauth else "no",
AnswerKeys.AI: "yes"
if any(
extract_base_service_name(s) == AnswerKeys.SERVICE_AI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ This Aegis Stack project includes the following components:
## Services

{%- if include_auth %}
- **Auth**: JWT authentication with user management
- **Auth**: JWT authentication with user management{% if include_oauth %} + GitHub/Google OAuth social login{% endif %}
{%- endif %}
{%- if include_ai %}
- **AI**: Multi-provider AI integration with PydanticAI
Expand Down Expand Up @@ -178,6 +178,70 @@ with db_session() as session:

{%- endif %}

{%- if include_oauth %}

### OAuth Social Login

GitHub and Google sign-in are wired up out of the box. Each provider
is enabled independently — leaving its client ID/secret blank turns
just that provider off (the route returns 503), so you can ship with
GitHub configured and add Google later.

**1. Create OAuth apps**

- **GitHub**: <https://github.com/settings/developers> → New OAuth App.
- Authorization callback URL:
`https://your-domain.example/api/v1/auth/oauth/github/callback`
- Or for local dev:
`http://localhost:8000/api/v1/auth/oauth/github/callback`
- **Google**: <https://console.cloud.google.com/apis/credentials> →
Create Credentials → OAuth client ID → Web application.
- Authorized redirect URI:
`https://your-domain.example/api/v1/auth/oauth/google/callback`

**2. Set environment variables**

```bash
# OAuth client credentials (leave blank to disable a provider)
GITHUB_OAUTH_CLIENT_ID=...
GITHUB_OAUTH_CLIENT_SECRET=...
GOOGLE_OAUTH_CLIENT_ID=...
GOOGLE_OAUTH_CLIENT_SECRET=...

# Secret for starlette's SessionMiddleware — Authlib stashes the
# OAuth state + PKCE verifier here between /start and /callback.
# MUST be overridden in any non-dev deployment.
OAUTH_SESSION_SECRET=replace-me-with-a-strong-random-string

# Optional: override the secure-flag heuristic. None (default) means
# off in dev, on otherwise. Set to false to run prod over HTTP without
# TLS — browsers refuse Secure cookies on insecure origins.
# SESSION_COOKIE_SECURE=
```

**3. Sign-in URLs**

- `GET /api/v1/auth/oauth/github/start?next=/dashboard`
- `GET /api/v1/auth/oauth/google/start?next=/dashboard`

The `?next=` parameter is honored only for same-origin paths. After a
successful callback the user is redirected to `next` (or `/app` by
default) with the JWT set in an HttpOnly `aegis_session` cookie. API
clients can keep using `Authorization: Bearer ...` — the cookie path
is parallel, not exclusive.

**4. Connection management**

Authenticated users can list and unlink their providers:

- `GET /api/v1/auth/oauth/connections`
- `DELETE /api/v1/auth/oauth/connections/{github|google}`

The disconnect route refuses to remove the last sign-in method to
avoid lock-out — set a password or link another provider first.

{%- endif %}

## Project Structure

```
Expand Down
Loading
Loading