Merge pull request #1283 from infinito-nexus/fix/web-app-fider/tenant-without-sso

fix(web-app-fider): bootstrap the tenant without SSO
This commit is contained in:
kevinveenbirkenbach authored and GitHub committed 2026-10-30 00:01:10 +01:00
commit 5995766f58
6 files changed
+124 -16

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
This role deploys Fider as part of the Infinito.Nexus stack using a single Docker Compose container backed by PostgreSQL. It configures SSO via Keycloak automatically on first deploy, optional email notifications via Mailu, and HTTPS via NGINX reverse proxy.
The setup tasks handle the full first-deploy bootstrap automatically:
The setup tasks handle the full first-deploy bootstrap automatically. Steps 1 to 3 run on every deploy, with or without SSO; step 4 runs only when `services.sso.enabled` is `true`:
1. **Tenant bootstrap:** calls Fider's `POST /_api/tenants` API to create the initial tenant.
2. **Admin user creation:** inserts the admin user directly into the `users` table, bypassing email verification. Idempotent via `WHERE NOT EXISTS`.
+5 -1
View File
@@ -20,8 +20,12 @@
- name: "🚿 Flush compose handlers for {{ application_id }}"
ansible.builtin.meta: flush_handlers
- name: "🥾 Fider | Tenant bootstrap"
include_tasks: 01_tenant.yml
when: IS_STACK_HOST | bool
- name: "🔧 Fider | OIDC setup"
include_tasks: 01_setup_oidc.yml
include_tasks: 02_setup_oidc.yml
when:
- FIDER_OIDC_ENABLED | bool
- IS_STACK_HOST | bool
@@ -85,17 +85,3 @@
query_file: "{{ role_path }}/files/sql/activate-tenants.sql"
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
changed_when: true
- name: "🔧 Fider | Configure Keycloak as OIDC provider in database"
database_query:
config: "{{ lookup('database', application_id) }}"
query_file: "{{ role_path }}/files/sql/configure-oidc-provider.sql"
named_args:
display_name: "{{ SOFTWARE_NAME }} SSO"
client_id: "{{ OIDC.CLIENT.ID }}"
client_secret: "{{ OIDC.CLIENT.SECRET }}"
authorize_url: "{{ OIDC.CLIENT.AUTHORIZE_URL }}"
token_url: "{{ OIDC.CLIENT.TOKEN_URL }}"
profile_url: "{{ OIDC.CLIENT.USER_INFO_URL }}"
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
changed_when: true
@@ -0,0 +1,14 @@
---
- name: "🔧 Fider | Configure Keycloak as OIDC provider in database"
database_query:
config: "{{ lookup('database', application_id) }}"
query_file: "{{ role_path }}/files/sql/configure-oidc-provider.sql"
named_args:
display_name: "{{ SOFTWARE_NAME }} SSO"
client_id: "{{ OIDC.CLIENT.ID }}"
client_secret: "{{ OIDC.CLIENT.SECRET }}"
authorize_url: "{{ OIDC.CLIENT.AUTHORIZE_URL }}"
token_url: "{{ OIDC.CLIENT.TOKEN_URL }}"
profile_url: "{{ OIDC.CLIENT.USER_INFO_URL }}"
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
changed_when: true
@@ -0,0 +1,3 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[5]
@@ -0,0 +1,101 @@
import unittest
from jinja2 import Environment
from utils.cache.yaml import load_yaml_any
from . import PROJECT_ROOT
TASKS = PROJECT_ROOT / "roles" / "web-app-fider" / "tasks"
MODES = ("compose", "swarm")
_TRUTHY = frozenset({"1", "true", "yes", "y", "on"})
def _to_bool(value):
if isinstance(value, bool):
return value
return str(value).strip().lower() in _TRUTHY
def _guard_holds(task, context) -> bool:
when = task.get("when", [])
guards = when if isinstance(when, list) else [when]
env = Environment(autoescape=True)
env.filters["bool"] = _to_bool
return all(bool(env.compile_expression(expr)(**context)) for expr in guards)
def _run_tasks(*, sso: bool, stack_host: bool, mode: str, tenants: int) -> list:
context = {
"FIDER_OIDC_ENABLED": sso,
"FIDER_MCP_ENABLED": False,
"IS_STACK_HOST": stack_host,
"DEPLOYMENT_MODE": mode,
"fider_tenant_count": {"rows": [[tenants]]},
}
executed: list = []
for include in load_yaml_any(str(TASKS / "00_core.yml")):
target = include.get("include_tasks")
if not isinstance(target, str) or not (TASKS / target).is_file():
continue
if not _guard_holds(include, context):
continue
executed.extend(
task
for task in load_yaml_any(str(TASKS / target))
if _guard_holds(task, context)
)
return executed
def _tenant_posts(tasks: list) -> list:
return [
task
for task in tasks
if str(task.get("uri_retry", {}).get("url", "")).endswith("/_api/tenants")
or "POST /_api/tenants" in str(task.get("ansible.builtin.shell", ""))
]
def _configures_keycloak(tasks: list) -> bool:
return any(
str(task.get("database_query", {}).get("query_file", "")).endswith(
"configure-oidc-provider.sql"
)
for task in tasks
)
class TestTenantBootstrapGate(unittest.TestCase):
def test_fresh_stack_host_posts_one_tenant_with_and_without_sso(self):
for sso in (True, False):
for mode in MODES:
with self.subTest(sso=sso, mode=mode):
tasks = _run_tasks(sso=sso, stack_host=True, mode=mode, tenants=0)
self.assertEqual(len(_tenant_posts(tasks)), 1)
def test_existing_tenant_is_not_posted_again(self):
for sso in (True, False):
for mode in MODES:
with self.subTest(sso=sso, mode=mode):
tasks = _run_tasks(sso=sso, stack_host=True, mode=mode, tenants=1)
self.assertEqual(_tenant_posts(tasks), [])
def test_keycloak_provider_is_configured_only_with_sso(self):
for sso in (True, False):
for mode in MODES:
with self.subTest(sso=sso, mode=mode):
tasks = _run_tasks(sso=sso, stack_host=True, mode=mode, tenants=1)
self.assertEqual(_configures_keycloak(tasks), sso)
def test_non_stack_host_runs_no_bootstrap(self):
for sso in (True, False):
for mode in MODES:
with self.subTest(sso=sso, mode=mode):
tasks = _run_tasks(sso=sso, stack_host=False, mode=mode, tenants=0)
self.assertEqual(tasks, [])
if __name__ == "__main__":
unittest.main()