Skip to content

Commit 527e663

Browse files
author
Aegis Stack
committed
Update Insights Overseer
1 parent d81fcca commit 527e663

28 files changed

Lines changed: 2968 additions & 850 deletions

aegis/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ class AnswerKeys:
167167
AUTH_LEVEL = "auth_level"
168168
AUTH_RBAC = "include_auth_rbac"
169169
AUTH_ORG = "include_auth_org"
170+
AUTH_OAUTH = "include_oauth"
170171
AI_VOICE = "ai_voice"
171172
OLLAMA_MODE = "ollama_mode"
172173
PROJECT_SLUG = "project_slug"

aegis/core/auth_service_parser.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""
22
Auth service bracket syntax parser.
33
4-
Parses auth[level, engine] syntax where values are detected by type:
4+
Parses auth[level, engine, modifier] syntax where values are detected
5+
by type:
6+
57
- Levels: basic, rbac, org
68
- Engines: sqlite, postgres
9+
- Modifiers: oauth (boolean toggle for social login)
710
8-
Order doesn't matter. Defaults: basic, (no engine override).
11+
Order doesn't matter. Defaults: basic, (no engine override), no
12+
modifiers.
913
"""
1014

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

1927
DEFAULT_LEVEL = AuthLevels.BASIC
2028

@@ -25,6 +33,7 @@ class AuthServiceConfig:
2533

2634
level: str
2735
engine: str | None = None
36+
oauth: bool = False
2837

2938

3039
def parse_auth_service_config(service_string: str) -> AuthServiceConfig:
@@ -76,17 +85,21 @@ def parse_auth_service_config(service_string: str) -> AuthServiceConfig:
7685

7786
found_levels: list[str] = []
7887
found_engines: list[str] = []
88+
found_modifiers: list[str] = []
7989

8090
for value in values:
8191
if value in LEVELS:
8292
found_levels.append(value)
8393
elif value in ENGINES:
8494
found_engines.append(value)
95+
elif value in MODIFIERS:
96+
found_modifiers.append(value)
8597
else:
8698
raise ValueError(
8799
f"Unknown value '{value}' in auth[...] syntax. "
88100
f"Valid levels: {', '.join(sorted(LEVELS))}. "
89-
f"Valid engines: {', '.join(sorted(ENGINES))}."
101+
f"Valid engines: {', '.join(sorted(ENGINES))}. "
102+
f"Valid modifiers: {', '.join(sorted(MODIFIERS))}."
90103
)
91104

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

117+
# Modifiers are flags — repeating one is a typo, not a different
118+
# selection, so reject it the same way duplicate levels/engines are
119+
# rejected.
120+
if len(found_modifiers) != len(set(found_modifiers)):
121+
duplicates = sorted(
122+
{m for m in found_modifiers if found_modifiers.count(m) > 1}
123+
)
124+
raise ValueError(
125+
f"Duplicate modifier(s) in auth[...] syntax: {', '.join(duplicates)}."
126+
)
127+
104128
level = found_levels[0] if found_levels else DEFAULT_LEVEL
105129
engine = found_engines[0] if found_engines else None
130+
oauth = "oauth" in found_modifiers
106131

107-
return AuthServiceConfig(level=level, engine=engine)
132+
return AuthServiceConfig(level=level, engine=engine, oauth=oauth)
108133

109134

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

aegis/core/copier_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ def generate_with_copier(
120120
AnswerKeys.AUTH_LEVEL: template_context.get(AnswerKeys.AUTH_LEVEL, "basic"),
121121
AnswerKeys.AUTH_RBAC: template_context.get(AnswerKeys.AUTH_RBAC, "no") == "yes",
122122
AnswerKeys.AUTH_ORG: template_context.get(AnswerKeys.AUTH_ORG, "no") == "yes",
123+
AnswerKeys.AUTH_OAUTH: template_context.get(AnswerKeys.AUTH_OAUTH, "no")
124+
== "yes",
123125
AnswerKeys.AI: template_context.get(AnswerKeys.AI, "no") == "yes",
124126
AnswerKeys.COMMS: template_context.get(AnswerKeys.COMMS, "no") == "yes",
125127
AnswerKeys.AI_FRAMEWORK: template_context.get(

aegis/core/migration_generator.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ class ForeignKeySpec:
5353
ref_columns: list[str]
5454

5555

56+
@dataclass
57+
class CheckConstraintSpec:
58+
"""Specification for a CHECK constraint on a table.
59+
60+
Used to enforce enum-style allowed values without a native PG enum type.
61+
Rendered into ``op.create_table`` so it works on both SQLite and Postgres.
62+
"""
63+
64+
name: str
65+
sqltext: str # e.g. "origin IN ('collector', 'user')"
66+
67+
5668
@dataclass
5769
class TableSpec:
5870
"""Specification for a database table."""
@@ -61,6 +73,7 @@ class TableSpec:
6173
columns: list[ColumnSpec]
6274
indexes: list[IndexSpec] = field(default_factory=list)
6375
foreign_keys: list[ForeignKeySpec] = field(default_factory=list)
76+
check_constraints: list[CheckConstraintSpec] = field(default_factory=list)
6477

6578

6679
@dataclass
@@ -141,6 +154,12 @@ class ServiceMigrationSpec:
141154
foreign_keys=[
142155
ForeignKeySpec(["user_id"], "user", ["id"]),
143156
],
157+
check_constraints=[
158+
CheckConstraintSpec(
159+
name="ck_user_oauth_identity_provider",
160+
sqltext="provider IN ('github', 'google')",
161+
),
162+
],
144163
),
145164
],
146165
)
@@ -662,6 +681,12 @@ class ServiceMigrationSpec:
662681
IndexSpec("ix_insight_event_origin", ["origin"]),
663682
IndexSpec("ix_insight_event_origin_date", ["origin", "date"]),
664683
],
684+
check_constraints=[
685+
CheckConstraintSpec(
686+
name="ck_insight_event_origin",
687+
sqltext="origin IN ('collector', 'user')",
688+
),
689+
],
665690
),
666691
],
667692
)
@@ -710,6 +735,27 @@ class ServiceMigrationSpec:
710735
foreign_keys=[
711736
ForeignKeySpec(["user_id"], "user", ["id"]),
712737
],
738+
check_constraints=[
739+
CheckConstraintSpec(
740+
name="ck_insight_goal_kind",
741+
sqltext="kind IN ('absolute', 'delta', 'rate')",
742+
),
743+
CheckConstraintSpec(
744+
name="ck_insight_goal_status",
745+
sqltext="status IN ('active', 'achieved', 'abandoned')",
746+
),
747+
CheckConstraintSpec(
748+
name="ck_insight_goal_metric_key",
749+
sqltext=(
750+
"metric_key IN ("
751+
"'github.stars', 'github.clones', 'github.unique_cloners', "
752+
"'github.views', 'github.unique_visitors', 'github.forks', "
753+
"'github.releases', 'pypi.downloads', 'docs.visitors', "
754+
"'docs.pageviews'"
755+
")"
756+
),
757+
),
758+
],
713759
),
714760
],
715761
alter_tables=[
@@ -956,11 +1002,15 @@ def upgrade() -> None:
9561002
sa.Column('{{ column.name }}', {{ column.type }}, nullable={{ column.nullable }}{{ pk_attr }}{{ default_attr }}),
9571003
{% endfor %}
9581004
{% if table.primary_keys %}
959-
sa.PrimaryKeyConstraint({% for pk in table.primary_keys %}'{{ pk }}'{% if not loop.last %}, {% endif %}{% endfor %}){% if table.foreign_keys %},{% endif %}
1005+
sa.PrimaryKeyConstraint({% for pk in table.primary_keys %}'{{ pk }}'{% if not loop.last %}, {% endif %}{% endfor %}){% if table.foreign_keys or table.check_constraints %},{% endif %}
9601006
9611007
{% endif %}
9621008
{% for fk in table.foreign_keys %}
963-
sa.ForeignKeyConstraint({{ fk.columns }}, ['{{ fk.ref_table }}.{{ fk.ref_columns[0] }}']){% if not loop.last %},{% endif %}
1009+
sa.ForeignKeyConstraint({{ fk.columns }}, ['{{ fk.ref_table }}.{{ fk.ref_columns[0] }}']){% if not loop.last or table.check_constraints %},{% endif %}
1010+
1011+
{% endfor %}
1012+
{% for chk in table.check_constraints %}
1013+
sa.CheckConstraint("{{ chk.sqltext }}", name='{{ chk.name }}'){% if not loop.last %},{% endif %}
9641014
9651015
{% endfor %}
9661016
)
@@ -1118,6 +1168,10 @@ def _render_migration(
11181168
}
11191169
for fk in table.foreign_keys
11201170
],
1171+
"check_constraints": [
1172+
{"name": chk.name, "sqltext": chk.sqltext}
1173+
for chk in table.check_constraints
1174+
],
11211175
"primary_keys": primary_keys,
11221176
}
11231177
)

aegis/core/post_gen_tasks.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,17 @@ def _rename_backend_files(suffix: str) -> set[str]:
499499
remove_file(project_path, "tests/services/test_goal_service.py")
500500
# Note: alembic removal is handled below based on whether ANY service needs migrations
501501

502+
# Remove OAuth (social login) files when not selected. Auth-only
503+
# projects without OAuth still have ``OAuthProvider`` /
504+
# ``UserOAuthIdentity`` SQLModels in ``app/models/user.py`` (the
505+
# tables ship with the auth migration unconditionally), but the
506+
# routes, middleware, settings, and tests are scoped here.
507+
if not is_enabled(AnswerKeys.AUTH_OAUTH):
508+
remove_file(project_path, "app/components/backend/api/auth/oauth.py")
509+
remove_file(project_path, "app/components/backend/middleware/session.py")
510+
remove_file(project_path, "tests/api/test_oauth_endpoints.py")
511+
remove_file(project_path, "tests/services/test_oauth_user_service.py")
512+
502513
# Remove auth org files if org level not selected (but auth is enabled)
503514
if is_enabled(AnswerKeys.AUTH) and not is_enabled(AnswerKeys.AUTH_ORG):
504515
remove_file(project_path, "app/models/org.py")

aegis/core/template_generator.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,13 @@ def __init__(
123123
user_specified_ai_backend = True
124124
break
125125

126-
# Extract auth level from auth[level] format in services
126+
# OAuth social login (GitHub + Google) — opt-in via the
127+
# ``auth[oauth]`` modifier in the bracket syntax (composes with
128+
# ``auth[rbac,oauth]`` etc.). Defaults off so projects that
129+
# don't ask for it don't pay for the extra routes and deps.
130+
self.include_oauth: bool = False
131+
132+
# Extract auth level (and oauth modifier) from auth[...] format in services
127133
self.auth_level = AuthLevels.BASIC # Default to basic
128134
self._user_specified_auth_level = False
129135
for service in self.selected_services:
@@ -132,6 +138,7 @@ def __init__(
132138
auth_config = parse_auth_service_config(service)
133139
self.auth_level = auth_config.level
134140
self._user_specified_auth_level = True
141+
self.include_oauth = auth_config.oauth
135142
break
136143

137144
# Extract insights sources from insights[sources] format in services
@@ -237,6 +244,12 @@ def get_template_context(self) -> dict[str, Any]:
237244
if auth_level in (AuthLevels.RBAC, AuthLevels.ORG)
238245
else "no",
239246
AnswerKeys.AUTH_ORG: "yes" if auth_level == AuthLevels.ORG else "no",
247+
# OAuth social login (GitHub + Google) — opt-in per project,
248+
# defaults off so existing flows aren't surprised by extra
249+
# routes and dependencies. Wire a CLI/bracket selector when
250+
# there's demand; until then, callers can flip this via the
251+
# underlying copier answer.
252+
AnswerKeys.AUTH_OAUTH: "yes" if self.include_oauth else "no",
240253
AnswerKeys.AI: "yes"
241254
if any(
242255
extract_base_service_name(s) == AnswerKeys.SERVICE_AI

aegis/templates/copier-aegis-project/{{ project_slug }}/README.md.jinja

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ This Aegis Stack project includes the following components:
2222
## Services
2323

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

179179
{%- endif %}
180180

181+
{%- if include_oauth %}
182+
183+
### OAuth Social Login
184+
185+
GitHub and Google sign-in are wired up out of the box. Each provider
186+
is enabled independently — leaving its client ID/secret blank turns
187+
just that provider off (the route returns 503), so you can ship with
188+
GitHub configured and add Google later.
189+
190+
**1. Create OAuth apps**
191+
192+
- **GitHub**: <https://github.com/settings/developers> → New OAuth App.
193+
- Authorization callback URL:
194+
`https://your-domain.example/api/v1/auth/oauth/github/callback`
195+
- Or for local dev:
196+
`http://localhost:8000/api/v1/auth/oauth/github/callback`
197+
- **Google**: <https://console.cloud.google.com/apis/credentials> →
198+
Create Credentials → OAuth client ID → Web application.
199+
- Authorized redirect URI:
200+
`https://your-domain.example/api/v1/auth/oauth/google/callback`
201+
202+
**2. Set environment variables**
203+
204+
```bash
205+
# OAuth client credentials (leave blank to disable a provider)
206+
GITHUB_OAUTH_CLIENT_ID=...
207+
GITHUB_OAUTH_CLIENT_SECRET=...
208+
GOOGLE_OAUTH_CLIENT_ID=...
209+
GOOGLE_OAUTH_CLIENT_SECRET=...
210+
211+
# Secret for starlette's SessionMiddleware — Authlib stashes the
212+
# OAuth state + PKCE verifier here between /start and /callback.
213+
# MUST be overridden in any non-dev deployment.
214+
OAUTH_SESSION_SECRET=replace-me-with-a-strong-random-string
215+
216+
# Optional: override the secure-flag heuristic. None (default) means
217+
# off in dev, on otherwise. Set to false to run prod over HTTP without
218+
# TLS — browsers refuse Secure cookies on insecure origins.
219+
# SESSION_COOKIE_SECURE=
220+
```
221+
222+
**3. Sign-in URLs**
223+
224+
- `GET /api/v1/auth/oauth/github/start?next=/dashboard`
225+
- `GET /api/v1/auth/oauth/google/start?next=/dashboard`
226+
227+
The `?next=` parameter is honored only for same-origin paths. After a
228+
successful callback the user is redirected to `next` (or `/app` by
229+
default) with the JWT set in an HttpOnly `aegis_session` cookie. API
230+
clients can keep using `Authorization: Bearer ...` — the cookie path
231+
is parallel, not exclusive.
232+
233+
**4. Connection management**
234+
235+
Authenticated users can list and unlink their providers:
236+
237+
- `GET /api/v1/auth/oauth/connections`
238+
- `DELETE /api/v1/auth/oauth/connections/{github|google}`
239+
240+
The disconnect route refuses to remove the last sign-in method to
241+
avoid lock-out — set a password or link another provider first.
242+
243+
{%- endif %}
244+
181245
## Project Structure
182246

183247
```

0 commit comments

Comments
 (0)