Repository navigation
ci(frontend): set ADMIN_DATABASE_URL for e2e backend #3749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6587226
6c5b2b4
7ad072f
a9e99f2
f171c83
2e6a19d
d6949bc
a75e365
a6fded3
9369cae
4a3ef4c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,19 @@ if (!externalBaseURL) { | |
| // per email domain), so the suites' combined traffic exceeds the 100/min | ||
| // per-tenant production default. | ||
| process.env.TENANT_RATE_LIMIT_RPM = process.env.TENANT_RATE_LIMIT_RPM || '100000'; | ||
| // authenticated-core-journeys creates 55 products; raise the product | ||
| // mutation budget only when this dedicated env var is set (server hook | ||
| // stays at 30/minute when unset). | ||
| process.env.REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT = | ||
| process.env.REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT || 'true'; | ||
| // Admin API-key store needs regengine_sysadmin (setup_e2e_postgres.py). | ||
| // CI sets ADMIN_DATABASE_URL; for local Playwright Postgres default it. | ||
| if (!process.env.ADMIN_DATABASE_URL && localSharedDatabaseUrl.includes('playwright_shared')) { | ||
| process.env.ADMIN_DATABASE_URL = | ||
| 'postgresql://regengine_sysadmin:ci-e2e-only-sysadmin-not-a-real-secret@127.0.0.1:5432/playwright_shared'; // pragma: allowlist secret — CI-only fake password for E2E NOSUPERUSER role | ||
|
Comment on lines
+71
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve the configured PostgreSQL endpoint in the admin URL. If
|
||
| } else if (!process.env.ADMIN_DATABASE_URL) { | ||
| process.env.ADMIN_DATABASE_URL = localSharedDatabaseUrl; | ||
| } | ||
| process.env.NEXT_PUBLIC_ENABLE_EXPERIMENTAL_FEATURES = | ||
| process.env.NEXT_PUBLIC_ENABLE_EXPERIMENTAL_FEATURES || 'true'; | ||
| } | ||
|
|
@@ -128,6 +141,12 @@ export default defineConfig({ | |
| REGENGINE_E2E_EMAIL_OUTBOX: | ||
| process.env.REGENGINE_E2E_EMAIL_OUTBOX || localEmailOutboxPath, | ||
| DATABASE_URL: process.env.DATABASE_URL || localSharedDatabaseUrl, | ||
| ADMIN_DATABASE_URL: | ||
| process.env.ADMIN_DATABASE_URL || | ||
| process.env.DATABASE_URL || | ||
| localSharedDatabaseUrl, | ||
| REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT: | ||
| process.env.REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT || 'true', | ||
| ADMIN_FALLBACK_SQLITE: | ||
| process.env.ADMIN_FALLBACK_SQLITE || localAdminDatabaseUrl, | ||
| WEBAUTHN_EXPECTED_ORIGIN: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,6 +70,21 @@ function isLocalE2E(): boolean { | |
| return !process.env.PLAYWRIGHT_BASE_URL; | ||
| } | ||
|
|
||
| /** | ||
| * Invite audit/cleanup helpers talk to node:sqlite against ADMIN_FALLBACK_SQLITE. | ||
| * When ADMIN_DATABASE_URL points at Postgres (CI Passwordless E2E / sysadmin | ||
| * API-key store), SessionLocal writes invites + audit_logs there instead, so | ||
| * the SQLite file has no invites table. Skip the SQLite assertions/cleanup in | ||
| * that mode; API + UI checks below still cover the journey. | ||
| */ | ||
| function adminUsesSqliteFallback(): boolean { | ||
| const adminUrl = (process.env.ADMIN_DATABASE_URL || '').trim().toLowerCase(); | ||
| if (adminUrl.startsWith('postgres')) return false; | ||
| if (adminUrl.startsWith('sqlite')) return true; | ||
| // Unset ADMIN_DATABASE_URL → local E2E uses ADMIN_FALLBACK_SQLITE. | ||
| return isLocalE2E(); | ||
| } | ||
|
|
||
| function parseInviteEmailOutboxLine(line: string): InviteEmailOutboxRecord | null { | ||
| try { | ||
| return JSON.parse(line) as InviteEmailOutboxRecord; | ||
|
|
@@ -99,6 +114,8 @@ function removeInviteEmailOutboxRecords(email: string): void { | |
| } | ||
|
|
||
| function expectAdminAuditLog(tenantId: string, eventType: string, resourceId: string): void { | ||
| if (!adminUsesSqliteFallback()) return; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Audit checks skipped on CI pathMedium Severity
Additional Locations (2)Triggered by project rule: Cursor Bugbot Review Guidelines Reviewed by Cursor Bugbot for commit d6949bc. Configure here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the audit assertion active for PostgreSQL. Line 117 makes As per path instructions, validation failures must be recorded as evidence, never swallowed.
|
||
|
|
||
| const database = new DatabaseSync(adminSqlitePath()); | ||
| try { | ||
| const row = database | ||
|
|
@@ -112,7 +129,7 @@ function expectAdminAuditLog(tenantId: string, eventType: string, resourceId: st | |
|
|
||
| function cleanupInviteFixture(tenantId: string | null, email: string, userId?: string, inviteId?: string): void { | ||
| removeInviteEmailOutboxRecords(email); | ||
| if (!tenantId || !isLocalE2E()) return; | ||
| if (!tenantId || !isLocalE2E() || !adminUsesSqliteFallback()) return; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add cleanup for local PostgreSQL fixtures. When local E2E uses PostgreSQL, Line 132 returns before deleting the created memberships, invites, and users. The As per path instructions, audit and hash-chain records are append-only.
|
||
|
|
||
| const database = new DatabaseSync(adminSqlitePath()); | ||
| try { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -94,6 +94,16 @@ async def _seed_api_key(tenant_id: str, email: str) -> None: | |
| def main() -> int: | ||
| _require_e2e_mode() | ||
|
|
||
| # Admin SessionLocal reads ADMIN_DATABASE_URL. CI points that at the | ||
| # NOSUPERUSER regengine_sysadmin role for the running app (api-key RLS). | ||
| # Fixture seeding must insert orgs/users as the postgres superuser on | ||
| # DATABASE_URL so RLS does not block bootstrap rows. Mutating env here | ||
| # only affects this seed process (Playwright execFileSync), not the app. | ||
| database_url = os.environ.get("DATABASE_URL", "").strip() | ||
| if not database_url: | ||
| raise SystemExit("DATABASE_URL is required to seed passwordless E2E users") | ||
| os.environ["ADMIN_DATABASE_URL"] = database_url | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| from services.admin.app.auth_crypto import AuthCryptoService | ||
| from services.admin.app.database import SessionLocal, init_db | ||
| from services.admin.app.sqlalchemy_models import ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -518,6 +518,12 @@ async def accept_invite( | |
| invite_tenant_id = _resolve_invite_tenant(db, token_hash) | ||
| if invite_tenant_id is not None: | ||
| TenantContext.set_tenant_context(db, invite_tenant_id) | ||
| # public.users / organization_memberships FORCE RLS policies for | ||
| # regengine_sysadmin require regengine.is_sysadmin (or a matching | ||
| # regengine.user_id) on WITH CHECK. Invite acceptance provisions a | ||
| # brand-new user row, so arm the admin GUC on this already-sysadmin | ||
| # connection. set_admin_context(true) is a no-op under SQLite. | ||
| TenantContext.set_admin_context(db, True) | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
Comment on lines
+521
to
+526
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| elif _uses_postgres(db): | ||
| # Fail closed: unknown, revoked, accepted or expired token. Proceeding | ||
| # would read invites with no tenant context and 500 instead of | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -114,6 +114,15 @@ async def list_users( | |
| if not tenant_id: | ||
| raise HTTPException(status_code=400, detail="Tenant context required") | ||
|
|
||
| # public.users FORCE RLS (users_self_or_admin) only exposes the caller's own | ||
| # row unless regengine.is_sysadmin is armed. memberships already permit | ||
| # tenant-scoped peer SELECT, so without this arm the UserModel join silently | ||
| # drops every co-member and the directory returns only the caller. Permission | ||
| # Checker("users.read") already authorized this request; keep the query | ||
| # scoped by MembershipModel.tenant_id so the arm does not widen tenants. | ||
| # set_admin_context(true) is a no-op under SQLite. | ||
| TenantContext.set_admin_context(db, True) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sysadmin RLS bypass in user listingHigh Severity
Triggered by project rule: Cursor Bugbot Review Guidelines Reviewed by Cursor Bugbot for commit a6fded3. Configure here. |
||
|
|
||
| # Count total | ||
| count_stmt = ( | ||
| select(func.count()) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,9 +33,37 @@ | |
| # human actions (operators curating the FTL list), so a modest per-client | ||
| # budget is ample while still blocking scripted abuse. GET routes are left | ||
| # unthrottled here. | ||
| _ADD_PRODUCT_RATE_LIMIT = "30/minute" | ||
| _UPDATE_PRODUCT_RATE_LIMIT = "30/minute" | ||
| _DELETE_PRODUCT_RATE_LIMIT = "30/minute" | ||
| # | ||
| # The production default is always ``30/minute``. An elevated budget is used | ||
| # only when ``REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT`` is explicitly set | ||
| # (the Passwordless Auth E2E job seeds 55 products in one minute). Never arm | ||
| # this from ``REGENGINE_E2E_MODE`` alone — production defaults must not change | ||
| # unless that dedicated env var is set. | ||
| _DEFAULT_PRODUCT_MUTATION_RATE_LIMIT = "30/minute" | ||
| _E2E_RELAXED_PRODUCT_MUTATION_RATE_LIMIT = "10000/minute" | ||
| _E2E_RELAX_PRODUCT_MUTATION_LIMIT_ENV = "REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT" | ||
| _E2E_RELAX_TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) | ||
|
|
||
|
|
||
| def resolve_product_mutation_rate_limit( | ||
| environ: dict[str, str] | None = None, | ||
| ) -> str: | ||
| """Return the product-mutation rate-limit string. | ||
|
|
||
| Unset / empty env → production default ``30/minute``. Truthy | ||
| ``REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT`` → elevated E2E budget. | ||
| """ | ||
| env = os.environ if environ is None else environ | ||
| raw = str(env.get(_E2E_RELAX_PRODUCT_MUTATION_LIMIT_ENV, "")).strip().lower() | ||
| if raw in _E2E_RELAX_TRUE_VALUES: | ||
| return _E2E_RELAXED_PRODUCT_MUTATION_RATE_LIMIT | ||
| return _DEFAULT_PRODUCT_MUTATION_RATE_LIMIT | ||
|
|
||
|
|
||
| # slowapi accepts a callable that returns the limit string per request. | ||
| _ADD_PRODUCT_RATE_LIMIT = resolve_product_mutation_rate_limit | ||
| _UPDATE_PRODUCT_RATE_LIMIT = resolve_product_mutation_rate_limit | ||
| _DELETE_PRODUCT_RATE_LIMIT = resolve_product_mutation_rate_limit | ||
|
Comment on lines
+63
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| # Bounded list-item type reused by request models. | ||
| _CatalogListItem = Annotated[str, StringConstraints(max_length=200)] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,24 @@ def _valid_body() -> dict: | |
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def test_product_mutation_rate_limit_default_when_relax_env_unset( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| ) -> None: | ||
| """Production default stays 30/minute when the E2E-only relax env is unset.""" | ||
| monkeypatch.delenv("REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT", raising=False) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isolate the flag for the route tests too. If
|
||
| assert pc.resolve_product_mutation_rate_limit({}) == "30/minute" | ||
| assert pc.resolve_product_mutation_rate_limit() == "30/minute" | ||
|
|
||
|
|
||
| def test_product_mutation_rate_limit_relaxed_when_env_set() -> None: | ||
| assert ( | ||
| pc.resolve_product_mutation_rate_limit( | ||
| {"REGENGINE_E2E_RELAX_PRODUCT_MUTATION_LIMIT": "true"} | ||
| ) | ||
| == "10000/minute" | ||
| ) | ||
|
|
||
|
Comment on lines
+100
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| def test_add_product_rate_limited() -> None: | ||
| client = TestClient(_build_app()) | ||
| saw_429 = False | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: regengine/RegEngine
Length of output: 13201
Script executed:
Repository: regengine/RegEngine
Length of output: 41427
Script executed:
Repository: regengine/RegEngine
Length of output: 33789
Script executed:
Repository: regengine/RegEngine
Length of output: 13549
Script executed:
Repository: regengine/RegEngine
Length of output: 13341
Script executed:
Repository: regengine/RegEngine
Length of output: 3202
Align the invite audit assertions with the selected admin database.
The CI job enables the invite test.
ADMIN_DATABASE_URLselects PostgreSQL for the backend, andAuditLogger.log_eventwrites both invite audit records through that PostgreSQL session. The test then queriesaudit_logsin/tmp/regengine-playwright-admin.db, which is a separate SQLite database with no mirroring from PostgreSQL. The first audit assertion can therefore fail, or fail because the SQLite table is absent.Change the invite audit assertions and cleanup to use the PostgreSQL database selected by
ADMIN_DATABASE_URL. Changing onlyADMIN_FALLBACK_SQLITEcannot fix this because the explicitADMIN_DATABASE_URLtakes precedence.