gaard-api 0.2.7__tar.gz → 0.2.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {gaard_api-0.2.7/src/gaard_api.egg-info → gaard_api-0.2.9}/PKG-INFO +5 -5
  2. {gaard_api-0.2.7 → gaard_api-0.2.9}/pyproject.toml +5 -5
  3. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin/database.py +243 -1
  4. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin/defaults.py +41 -0
  5. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin/models.py +57 -1
  6. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin/prompt_runtime.py +49 -0
  7. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin/services.py +1 -0
  8. gaard_api-0.2.9/src/gaard_api/admin-web/assets/identity.js +211 -0
  9. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/main.js +599 -132
  10. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/styles.css +397 -54
  11. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/v1/admin.py +963 -59
  12. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/v1/analysis.py +13 -6
  13. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/v1/dashboards.py +39 -36
  14. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/v1/prompts.py +2 -2
  15. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/v1/query.py +407 -62
  16. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/v1/schema.py +3 -3
  17. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api_registry.py +30 -0
  18. gaard_api-0.2.9/src/gaard_api/auth_dependencies.py +195 -0
  19. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/auth_hooks.py +15 -1
  20. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/conversations.py +5 -4
  21. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/core/settings.py +1 -1
  22. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/example_database.py +43 -33
  23. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/extensions.py +2 -2
  24. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/license.py +69 -15
  25. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/main.py +5 -1
  26. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/package_updates.py +3 -49
  27. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/query_hooks.py +90 -1
  28. {gaard_api-0.2.7 → gaard_api-0.2.9/src/gaard_api.egg-info}/PKG-INFO +5 -5
  29. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api.egg-info/SOURCES.txt +1 -0
  30. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api.egg-info/requires.txt +4 -4
  31. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_admin_api.py +1298 -89
  32. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_analysis_api.py +2 -2
  33. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_api_registry.py +15 -0
  34. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_license_api.py +77 -5
  35. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_license_service.py +79 -1
  36. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_prompt_runtime.py +38 -0
  37. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_server_cli.py +9 -5
  38. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_settings.py +1 -0
  39. gaard_api-0.2.7/src/gaard_api/auth_dependencies.py +0 -98
  40. {gaard_api-0.2.7 → gaard_api-0.2.9}/MANIFEST.in +0 -0
  41. {gaard_api-0.2.7 → gaard_api-0.2.9}/README.md +0 -0
  42. {gaard_api-0.2.7 → gaard_api-0.2.9}/setup.cfg +0 -0
  43. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/__init__.py +0 -0
  44. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin/__init__.py +0 -0
  45. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin/security.py +0 -0
  46. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  47. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  48. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  49. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin-web/index.html +0 -0
  50. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/admin-web/package.json +0 -0
  51. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/__init__.py +0 -0
  52. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/api/v1/__init__.py +0 -0
  53. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/cli.py +0 -0
  54. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/cli_commands.py +0 -0
  55. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/core/__init__.py +0 -0
  56. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/core/error_handlers.py +0 -0
  57. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/core/schema_cache.py +0 -0
  58. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/example_data/__init__.py +0 -0
  59. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  60. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  61. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  62. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/extension_services.py +0 -0
  63. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/py.typed +0 -0
  64. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/server_cli.py +0 -0
  65. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/siem.py +0 -0
  66. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api/tls_http.py +0 -0
  67. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  68. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api.egg-info/entry_points.txt +0 -0
  69. {gaard_api-0.2.7 → gaard_api-0.2.9}/src/gaard_api.egg-info/top_level.txt +0 -0
  70. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_conversation_api.py +0 -0
  71. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_error_handlers.py +0 -0
  72. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_query_error_messages.py +0 -0
  73. {gaard_api-0.2.7 → gaard_api-0.2.9}/tests/test_tls_http.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.7
8
- Requires-Dist: gaard-connectors==0.2.7
9
- Requires-Dist: gaard-llm==0.2.7
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.7
7
+ Requires-Dist: gaard-core==0.2.9
8
+ Requires-Dist: gaard-connectors==0.2.9
9
+ Requires-Dist: gaard-llm==0.2.9
10
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.9
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
13
13
  Requires-Dist: pydantic>=2.7.0
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.7"
7
+ version = "0.2.9"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
- "gaard-core==0.2.7",
14
- "gaard-connectors==0.2.7",
15
- "gaard-llm==0.2.7",
16
- "gaard-plugin-api>=0.2.7,<0.3.0",
13
+ "gaard-core==0.2.9",
14
+ "gaard-connectors==0.2.9",
15
+ "gaard-llm==0.2.9",
16
+ "gaard-plugin-api>=0.2.9,<0.3.0",
17
17
  "fastapi>=0.111.0",
18
18
  "uvicorn[standard]>=0.30.0",
19
19
  "pydantic>=2.7.0",
@@ -1,6 +1,7 @@
1
1
  from collections.abc import Iterator
2
2
  import json
3
3
  import threading
4
+ from datetime import UTC, datetime, timedelta
4
5
 
5
6
  from sqlalchemy import create_engine, delete, select
6
7
  from sqlalchemy.engine import Engine
@@ -17,7 +18,10 @@ from gaard_api.admin.models import (
17
18
  DataQueryAuditType,
18
19
  DatasourceConnector,
19
20
  OverviewWidget,
21
+ OverviewWidgetTag,
20
22
  PromptTemplate,
23
+ UserSavedMetric,
24
+ WidgetTag,
21
25
  )
22
26
  from gaard_api.admin.security import hash_password
23
27
  from gaard_api.core.settings import settings
@@ -75,6 +79,7 @@ def init_metadata_store() -> None:
75
79
  engine = get_engine()
76
80
  Base.metadata.create_all(engine)
77
81
  ensure_admin_session_schema(engine)
82
+ ensure_admin_user_schema(engine)
78
83
  ensure_data_query_audit_schema(engine)
79
84
  ensure_overview_widget_schema(engine)
80
85
 
@@ -88,12 +93,29 @@ def init_metadata_store() -> None:
88
93
  seed_prompts(session)
89
94
  seed_datasource_connectors(session)
90
95
  seed_overview_widgets(session)
96
+ backfill_overview_widget_tags(session)
91
97
  backfill_data_query_audit_types(session)
92
98
  session.commit()
93
99
 
94
100
 
101
+ def clear_expired_admin_sessions(session: Session) -> int:
102
+ """Remove sessions inactive for 30 days; called once during application startup."""
103
+ cutoff = datetime.now(UTC) - timedelta(days=30)
104
+ result = session.execute(
105
+ delete(AdminSession)
106
+ .where(AdminSession.last_seen < cutoff)
107
+ .execution_options(synchronize_session=False)
108
+ )
109
+ return int(result.rowcount or 0)
110
+
111
+
95
112
  def seed_admin_user(session: Session) -> None:
96
- user = session.scalar(select(AdminUser).where(AdminUser.username == "admin"))
113
+ user = session.scalar(
114
+ select(AdminUser).where(
115
+ AdminUser.username == "admin",
116
+ AdminUser.auth_provider == "local",
117
+ )
118
+ )
97
119
 
98
120
  if user is not None:
99
121
  return
@@ -103,10 +125,177 @@ def seed_admin_user(session: Session) -> None:
103
125
  username="admin",
104
126
  password_hash=hash_password("admin"),
105
127
  must_change_password=True,
128
+ is_system_admin=True,
129
+ enterprise_access=True,
106
130
  )
107
131
  )
108
132
 
109
133
 
134
+ def ensure_admin_user_schema(engine: Engine) -> None:
135
+ columns = {column["name"] for column in inspect(engine).get_columns("admin_users")}
136
+ additions = {
137
+ "display_name": "ALTER TABLE admin_users ADD COLUMN display_name VARCHAR(255) NOT NULL DEFAULT ''",
138
+ "auth_provider": "ALTER TABLE admin_users ADD COLUMN auth_provider VARCHAR(255) NOT NULL DEFAULT 'local'",
139
+ "role": "ALTER TABLE admin_users ADD COLUMN role VARCHAR(50) NOT NULL DEFAULT 'admin'",
140
+ "is_system_admin": "ALTER TABLE admin_users ADD COLUMN is_system_admin BOOLEAN NOT NULL DEFAULT 0",
141
+ "enterprise_access": "ALTER TABLE admin_users ADD COLUMN enterprise_access BOOLEAN NOT NULL DEFAULT 0",
142
+ "is_provisioned": "ALTER TABLE admin_users ADD COLUMN is_provisioned BOOLEAN NOT NULL DEFAULT 0",
143
+ }
144
+ with engine.begin() as connection:
145
+ for name, sql in additions.items():
146
+ if name not in columns:
147
+ connection.execute(text(sql))
148
+ # Existing installations predate the explicit system-admin marker. The
149
+ # first local administrator is the account created during initialization.
150
+ connection.execute(text("""
151
+ UPDATE admin_users
152
+ SET is_system_admin = 1
153
+ WHERE id = (
154
+ SELECT id FROM admin_users
155
+ WHERE auth_provider = 'local' AND role = 'admin'
156
+ ORDER BY id
157
+ LIMIT 1
158
+ )
159
+ AND NOT EXISTS (
160
+ SELECT 1 FROM admin_users WHERE is_system_admin = 1
161
+ )
162
+ """))
163
+ # The system administrator is the sole account with permanent Enterprise
164
+ # access. Other administrators receive a seat explicitly, like users.
165
+ connection.execute(text(
166
+ "UPDATE admin_users SET enterprise_access = 1 WHERE is_system_admin = 1"
167
+ ))
168
+ if engine.dialect.name == "sqlite" and (
169
+ admin_user_has_global_username_constraint(engine)
170
+ or not admin_user_has_provider_username_constraint(engine)
171
+ ):
172
+ rebuild_sqlite_admin_users(connection)
173
+ else:
174
+ drop_global_admin_username_constraint(connection, engine)
175
+ normalize_external_admin_usernames(connection, engine.dialect.name)
176
+ ensure_admin_user_provider_username_constraint(connection, engine)
177
+
178
+
179
+ def admin_user_has_global_username_constraint(engine: Engine) -> bool:
180
+ return ["username"] in admin_user_unique_column_sets(engine)
181
+
182
+
183
+ def admin_user_has_provider_username_constraint(engine: Engine) -> bool:
184
+ return ["auth_provider", "username"] in admin_user_unique_column_sets(engine)
185
+
186
+
187
+ def admin_user_unique_column_sets(engine: Engine) -> list[list[str]]:
188
+ if engine.dialect.name == "sqlite":
189
+ with engine.connect() as connection:
190
+ return [
191
+ [row[2] for row in connection.execute(text(f"PRAGMA index_info({index_name!r})"))]
192
+ for _sequence, index_name, is_unique, *_rest in connection.execute(
193
+ text("PRAGMA index_list('admin_users')")
194
+ )
195
+ if is_unique
196
+ ]
197
+
198
+ inspector = inspect(engine)
199
+ return [
200
+ cast(list[str], constraint_column_names)
201
+ for constraint in inspector.get_unique_constraints("admin_users")
202
+ if isinstance((constraint_column_names := constraint.get("column_names")), list)
203
+ and all(isinstance(column_name, str) for column_name in constraint_column_names)
204
+ ] + [
205
+ cast(list[str], index_column_names)
206
+ for index in inspector.get_indexes("admin_users")
207
+ if index.get("unique")
208
+ and isinstance((index_column_names := index.get("column_names")), list)
209
+ and all(isinstance(column_name, str) for column_name in index_column_names)
210
+ ]
211
+
212
+
213
+ def rebuild_sqlite_admin_users(connection) -> None:
214
+ connection.execute(text("""
215
+ CREATE TABLE admin_users__migrated (
216
+ id INTEGER NOT NULL PRIMARY KEY,
217
+ username VARCHAR(255) NOT NULL,
218
+ display_name VARCHAR(255) NOT NULL DEFAULT '',
219
+ auth_provider VARCHAR(255) NOT NULL DEFAULT 'local',
220
+ role VARCHAR(50) NOT NULL DEFAULT 'admin',
221
+ is_system_admin BOOLEAN NOT NULL DEFAULT 0,
222
+ enterprise_access BOOLEAN NOT NULL DEFAULT 0,
223
+ password_hash TEXT NOT NULL,
224
+ must_change_password BOOLEAN NOT NULL,
225
+ is_provisioned BOOLEAN NOT NULL DEFAULT 0,
226
+ created_at DATETIME NOT NULL,
227
+ updated_at DATETIME NOT NULL,
228
+ CONSTRAINT uq_admin_users_auth_provider_username UNIQUE (auth_provider, username)
229
+ )
230
+ """))
231
+ connection.execute(text("""
232
+ INSERT INTO admin_users__migrated
233
+ (id, username, display_name, auth_provider, role, is_system_admin, enterprise_access, password_hash, must_change_password, is_provisioned, created_at, updated_at)
234
+ SELECT
235
+ id,
236
+ CASE WHEN password_hash = 'external$disabled' AND instr(username, ':') > 0
237
+ AND (auth_provider = 'local' OR substr(username, 1, instr(username, ':') - 1) = auth_provider)
238
+ THEN substr(username, instr(username, ':') + 1) ELSE username END,
239
+ display_name,
240
+ CASE WHEN auth_provider = 'local' AND password_hash = 'external$disabled' AND instr(username, ':') > 0
241
+ THEN substr(username, 1, instr(username, ':') - 1) ELSE auth_provider END,
242
+ role,
243
+ is_system_admin,
244
+ enterprise_access,
245
+ password_hash, must_change_password, 0, created_at, updated_at
246
+ FROM admin_users
247
+ """))
248
+ connection.execute(text("DROP TABLE admin_users"))
249
+ connection.execute(text("ALTER TABLE admin_users__migrated RENAME TO admin_users"))
250
+ connection.execute(text("CREATE INDEX ix_admin_users_username ON admin_users (username)"))
251
+ connection.execute(text("CREATE INDEX ix_admin_users_auth_provider ON admin_users (auth_provider)"))
252
+
253
+
254
+ def drop_global_admin_username_constraint(connection, engine: Engine) -> None:
255
+ if engine.dialect.name != "postgresql":
256
+ return
257
+ inspector = inspect(engine)
258
+ quote = connection.dialect.identifier_preparer.quote
259
+ for constraint in inspector.get_unique_constraints("admin_users"):
260
+ if constraint.get("column_names") == ["username"]:
261
+ connection.execute(text(f"ALTER TABLE admin_users DROP CONSTRAINT {quote(constraint['name'])}"))
262
+
263
+
264
+ def normalize_external_admin_usernames(connection, dialect_name: str) -> None:
265
+ if dialect_name == "sqlite":
266
+ username = "substr(username, instr(username, ':') + 1)"
267
+ provider = "substr(username, 1, instr(username, ':') - 1)"
268
+ has_prefix = "instr(username, ':') > 0"
269
+ prefix_matches_provider = "substr(username, 1, instr(username, ':') - 1) = auth_provider"
270
+ elif dialect_name == "postgresql":
271
+ username = "substring(username from position(':' in username) + 1)"
272
+ provider = "substring(username from 1 for position(':' in username) - 1)"
273
+ has_prefix = "position(':' in username) > 0"
274
+ prefix_matches_provider = "substring(username from 1 for position(':' in username) - 1) = auth_provider"
275
+ else:
276
+ return
277
+ connection.execute(text(
278
+ "UPDATE admin_users "
279
+ f"SET username = {username}, auth_provider = CASE WHEN auth_provider = 'local' THEN {provider} ELSE auth_provider END "
280
+ "WHERE password_hash = 'external$disabled' "
281
+ f"AND {has_prefix} AND (auth_provider = 'local' OR {prefix_matches_provider})"
282
+ ))
283
+
284
+
285
+ def ensure_admin_user_provider_username_constraint(connection, engine: Engine) -> None:
286
+ if engine.dialect.name != "postgresql":
287
+ return
288
+ constraints = inspect(engine).get_unique_constraints("admin_users")
289
+ if not any(
290
+ constraint.get("column_names") == ["auth_provider", "username"]
291
+ for constraint in constraints
292
+ ):
293
+ connection.execute(text(
294
+ "ALTER TABLE admin_users ADD CONSTRAINT uq_admin_users_auth_provider_username "
295
+ "UNIQUE (auth_provider, username)"
296
+ ))
297
+
298
+
110
299
  def seed_settings(session: Session) -> None:
111
300
  defaults = {
112
301
  "gaard_intent_classification_mode": settings.gaard_intent_classification_mode,
@@ -278,6 +467,7 @@ def seed_overview_widgets(session: Session) -> None:
278
467
  "result_mode": "data",
279
468
  "position": 10,
280
469
  "grid_width": 1,
470
+ "grid_height": 2,
281
471
  },
282
472
  {
283
473
  "widget_key": "audit_retention",
@@ -296,6 +486,7 @@ def seed_overview_widgets(session: Session) -> None:
296
486
  "result_mode": "data",
297
487
  "position": 20,
298
488
  "grid_width": 1,
489
+ "grid_height": 2,
299
490
  },
300
491
  {
301
492
  "widget_key": "schema_cache_ttl",
@@ -314,6 +505,7 @@ def seed_overview_widgets(session: Session) -> None:
314
505
  "result_mode": "data",
315
506
  "position": 30,
316
507
  "grid_width": 1,
508
+ "grid_height": 2,
317
509
  },
318
510
  {
319
511
  "widget_key": "license_edition",
@@ -328,6 +520,7 @@ def seed_overview_widgets(session: Session) -> None:
328
520
  "result_mode": "data",
329
521
  "position": 40,
330
522
  "grid_width": 1,
523
+ "grid_height": 2,
331
524
  },
332
525
  {
333
526
  "widget_key": "runtime_daily_queries",
@@ -343,6 +536,7 @@ def seed_overview_widgets(session: Session) -> None:
343
536
  "result_mode": "data",
344
537
  "position": 250,
345
538
  "grid_width": 12,
539
+ "grid_height": 4,
346
540
  "active": False,
347
541
  },
348
542
  {
@@ -362,6 +556,7 @@ def seed_overview_widgets(session: Session) -> None:
362
556
  "result_mode": "data",
363
557
  "position": 130,
364
558
  "grid_width": 12,
559
+ "grid_height": 4,
365
560
  },
366
561
  ]
367
562
 
@@ -446,12 +641,20 @@ def ensure_admin_session_schema(engine: Engine) -> None:
446
641
  "auth_provider": (
447
642
  "ALTER TABLE admin_sessions ADD COLUMN auth_provider VARCHAR(255) DEFAULT 'local'"
448
643
  ),
644
+ # SQLite cannot add a column with CURRENT_TIMESTAMP as its default. Backfill
645
+ # legacy rows below, while newly created sessions use the ORM default.
646
+ "last_seen": "ALTER TABLE admin_sessions ADD COLUMN last_seen DATETIME",
449
647
  }
450
648
  with engine.begin() as connection:
451
649
  for column_name, sql in additions.items():
452
650
  if column_name not in columns:
453
651
  connection.execute(text(sql))
454
652
 
653
+ if "last_seen" not in columns:
654
+ connection.execute(
655
+ text("UPDATE admin_sessions SET last_seen = created_at WHERE last_seen IS NULL")
656
+ )
657
+
455
658
  session_table = cast(Table, AdminSession.__table__)
456
659
  for index in session_table.indexes:
457
660
  index.create(bind=connection, checkfirst=True)
@@ -516,6 +719,18 @@ def ensure_overview_widget_schema(engine: Engine) -> None:
516
719
  text("ALTER TABLE overview_widgets ADD COLUMN grid_width INTEGER DEFAULT 1")
517
720
  )
518
721
 
722
+ if "grid_height" not in columns:
723
+ with engine.begin() as connection:
724
+ connection.execute(
725
+ text("ALTER TABLE overview_widgets ADD COLUMN grid_height INTEGER DEFAULT 2")
726
+ )
727
+ connection.execute(
728
+ text(
729
+ "UPDATE overview_widgets SET grid_height = 4 "
730
+ "WHERE widget_type <> 'scalar'"
731
+ )
732
+ )
733
+
519
734
  if "result_mode" not in columns:
520
735
  with engine.begin() as connection:
521
736
  connection.execute(
@@ -525,6 +740,33 @@ def ensure_overview_widget_schema(engine: Engine) -> None:
525
740
  )
526
741
  )
527
742
 
743
+ def backfill_overview_widget_tags(session: Session) -> None:
744
+ """Give legacy widgets public and saved-metric owner tags."""
745
+ if session.scalar(select(WidgetTag).where(WidgetTag.name == "public")) is None:
746
+ session.add(WidgetTag(name="public"))
747
+ session.flush()
748
+ widget_ids_with_tags = set(session.scalars(select(OverviewWidgetTag.widget_id)))
749
+ for widget_id in session.scalars(select(OverviewWidget.id)):
750
+ if widget_id not in widget_ids_with_tags:
751
+ session.add(OverviewWidgetTag(widget_id=widget_id, tag_name="public"))
752
+
753
+ existing_assignments = set(
754
+ session.execute(select(OverviewWidgetTag.widget_id, OverviewWidgetTag.tag_name))
755
+ )
756
+ for widget_id, owner_username in session.execute(
757
+ select(OverviewWidget.id, UserSavedMetric.owner_username).join(
758
+ UserSavedMetric,
759
+ UserSavedMetric.widget_key == OverviewWidget.widget_key,
760
+ )
761
+ ):
762
+ if not owner_username:
763
+ continue
764
+ if session.get(WidgetTag, owner_username) is None:
765
+ session.add(WidgetTag(name=owner_username))
766
+ session.flush()
767
+ if (widget_id, owner_username) not in existing_assignments:
768
+ session.add(OverviewWidgetTag(widget_id=widget_id, tag_name=owner_username))
769
+
528
770
 
529
771
  def reset_metadata_store_for_tests() -> None:
530
772
  global _engine, _engine_url, _session_factory
@@ -218,6 +218,40 @@ Return exactly one of:
218
218
  personal_data, sensitive_data, technical_data, neutral_data, unknown
219
219
  """
220
220
 
221
+ DEFAULT_ANSWER_EXPLANATION_SYSTEM_PROMPT = """You are GAARD Answer Explanation.
222
+
223
+ Your task is to explain why a specific SQL query was generated for the user's data question.
224
+
225
+ Use the provided:
226
+ - original user question,
227
+ - generated SQL,
228
+ - interpreted answer and raw result rows,
229
+ - inference metadata,
230
+ - prompt metadata,
231
+ - approved business logic and schema/rule context.
232
+
233
+ Rules:
234
+ 1. Answer in the same language as the user's question.
235
+ 2. Explain the logical mapping from the question to the SQL.
236
+ 3. Mention the selected tables, columns, joins, filters, time ranges, grouping,
237
+ aggregation, ordering and limits when they are relevant.
238
+ 4. If business logic affected the SQL, describe that rule in plain language.
239
+ 5. Distinguish explicit evidence from assumptions.
240
+ 6. Do not invent tables, columns, rules, filters or results that are not present in the input.
241
+ 7. Do not reveal hidden chain-of-thought.
242
+ 8. Do not include <think> blocks.
243
+ 9. Do not use markdown tables.
244
+ 10. Return only the final explanation.
245
+ """
246
+
247
+ DEFAULT_ANSWER_EXPLANATION_USER_PROMPT = """Explain why this SQL was generated for the user's question.
248
+
249
+ Input JSON:
250
+ {payload}
251
+
252
+ Return only the final explanation.
253
+ """
254
+
221
255
  DEFAULT_GOVERNANCE_POLICY_CONFIG = {
222
256
  "final_answer": {
223
257
  "record_level_pii_allowed": False,
@@ -280,4 +314,11 @@ DEFAULT_PROMPTS = [
280
314
  "system_prompt": DEFAULT_RESULT_CLASSIFICATION_SYSTEM_PROMPT,
281
315
  "user_prompt_template": DEFAULT_RESULT_CLASSIFICATION_USER_PROMPT,
282
316
  },
317
+ {
318
+ "prompt_key": "answer_explanation",
319
+ "name": "Answer explanation",
320
+ "description": "Explains why a generated SQL query answered a user question.",
321
+ "system_prompt": DEFAULT_ANSWER_EXPLANATION_SYSTEM_PROMPT,
322
+ "user_prompt_template": DEFAULT_ANSWER_EXPLANATION_USER_PROMPT,
323
+ },
283
324
  ]
@@ -6,6 +6,7 @@ from sqlalchemy import (
6
6
  DateTime,
7
7
  Enum as SAEnum,
8
8
  Float,
9
+ ForeignKey,
9
10
  Integer,
10
11
  String,
11
12
  Text,
@@ -26,11 +27,26 @@ class Base(DeclarativeBase):
26
27
 
27
28
  class AdminUser(Base):
28
29
  __tablename__ = "admin_users"
30
+ __table_args__ = (
31
+ UniqueConstraint("auth_provider", "username", name="uq_admin_users_auth_provider_username"),
32
+ )
29
33
 
30
34
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
31
- username: Mapped[str] = mapped_column(String(255), unique=True, index=True)
35
+ username: Mapped[str] = mapped_column(String(255), index=True)
36
+ display_name: Mapped[str] = mapped_column(String(255), default="")
37
+ auth_provider: Mapped[str] = mapped_column(String(255), index=True, default="local")
38
+ role: Mapped[str] = mapped_column(String(50), index=True, default="admin")
39
+ # The administrator created while initializing a GAARD installation.
40
+ # This flag is immutable through identity management.
41
+ is_system_admin: Mapped[bool] = mapped_column(Boolean, default=False)
42
+ # Enterprise access is assigned explicitly to every human identity except
43
+ # the system administrator, which always retains it.
44
+ enterprise_access: Mapped[bool] = mapped_column(Boolean, default=False)
32
45
  password_hash: Mapped[str] = mapped_column(Text)
33
46
  must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
47
+ # Provider refreshes create provisional records so they can be assigned before login.
48
+ # The provider may safely remove only records which remain provisional.
49
+ is_provisioned: Mapped[bool] = mapped_column(Boolean, default=False)
34
50
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
35
51
  updated_at: Mapped[datetime] = mapped_column(
36
52
  DateTime(timezone=True),
@@ -49,6 +65,9 @@ class AdminSession(Base):
49
65
  role: Mapped[str] = mapped_column(String(50), index=True, default="admin")
50
66
  auth_provider: Mapped[str] = mapped_column(String(255), index=True, default="local")
51
67
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
68
+ last_seen: Mapped[datetime] = mapped_column(
69
+ DateTime(timezone=True), index=True, default=utc_now
70
+ )
52
71
 
53
72
 
54
73
  class AdminAuditLog(Base):
@@ -180,6 +199,22 @@ class DashboardUserState(Base):
180
199
  )
181
200
 
182
201
 
202
+ class UserDatasourceSelection(Base):
203
+ """Client datasource selections, scoped to one authenticated user."""
204
+
205
+ __tablename__ = "user_datasource_selections"
206
+
207
+ owner_user_id: Mapped[str] = mapped_column(String(255), primary_key=True)
208
+ owner_username: Mapped[str] = mapped_column(String(255), index=True, default="")
209
+ datasource_ids_json: Mapped[str] = mapped_column(Text, default="[]")
210
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
211
+ updated_at: Mapped[datetime] = mapped_column(
212
+ DateTime(timezone=True),
213
+ default=utc_now,
214
+ onupdate=utc_now,
215
+ )
216
+
217
+
183
218
  class UserSavedMetric(Base):
184
219
  __tablename__ = "user_saved_metrics"
185
220
  __table_args__ = (
@@ -303,6 +338,7 @@ class OverviewWidget(Base):
303
338
  result_mode: Mapped[str] = mapped_column(String(50), default="data")
304
339
  position: Mapped[int] = mapped_column(Integer, default=100)
305
340
  grid_width: Mapped[int] = mapped_column(Integer, default=1)
341
+ grid_height: Mapped[int] = mapped_column(Integer, default=2)
306
342
  active: Mapped[bool] = mapped_column(Boolean, default=True)
307
343
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
308
344
  updated_at: Mapped[datetime] = mapped_column(
@@ -313,6 +349,26 @@ class OverviewWidget(Base):
313
349
  updated_by: Mapped[str] = mapped_column(String(255), default="system")
314
350
 
315
351
 
352
+ class WidgetTag(Base):
353
+ """Permanent catalogue of every tag that has been used on a widget."""
354
+
355
+ __tablename__ = "widget_tags"
356
+
357
+ name: Mapped[str] = mapped_column(String(255), primary_key=True)
358
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
359
+
360
+
361
+ class OverviewWidgetTag(Base):
362
+ __tablename__ = "overview_widget_tags"
363
+
364
+ widget_id: Mapped[int] = mapped_column(
365
+ Integer, ForeignKey("overview_widgets.id", ondelete="CASCADE"), primary_key=True
366
+ )
367
+ tag_name: Mapped[str] = mapped_column(
368
+ String(255), ForeignKey("widget_tags.name"), primary_key=True
369
+ )
370
+
371
+
316
372
  class BusinessLogicSuggestion(Base):
317
373
  __tablename__ = "business_logic_suggestions"
318
374
 
@@ -212,6 +212,44 @@ class MetadataResultClassificationPromptCompiler:
212
212
  )
213
213
 
214
214
 
215
+ class MetadataAnswerExplanationPromptCompiler:
216
+ def __init__(self, prompt_template: PromptTemplate) -> None:
217
+ self.prompt_template = prompt_template
218
+
219
+ def compile(self, payload: dict[str, Any]) -> CompiledPrompt:
220
+ payload_json = json_dumps(payload, ensure_ascii=False, indent=2)
221
+ metadata = payload.get("metadata")
222
+ inference_metadata = payload.get("inference_metadata")
223
+ prompt_metadata = payload.get("prompt_metadata")
224
+
225
+ return CompiledPrompt(
226
+ system_prompt=self.prompt_template.system_prompt,
227
+ user_prompt=self.prompt_template.user_prompt_template.format(
228
+ payload=payload_json,
229
+ question=str(payload.get("question") or ""),
230
+ sql=str(payload.get("sql") or ""),
231
+ answer=str(payload.get("answer") or ""),
232
+ metadata=json_dumps(metadata, ensure_ascii=False),
233
+ inference_metadata=json_dumps(inference_metadata, ensure_ascii=False),
234
+ prompt_metadata=json_dumps(prompt_metadata, ensure_ascii=False),
235
+ business_logic=str(payload.get("business_logic") or ""),
236
+ ),
237
+ metadata={
238
+ "prompt_key": self.prompt_template.prompt_key,
239
+ "prompt_version": self.prompt_template.version,
240
+ "has_sql": bool(str(payload.get("sql") or "").strip()),
241
+ "has_result_rows": bool(
242
+ (payload.get("result") or {}).get("rows")
243
+ if isinstance(payload.get("result"), dict)
244
+ else False
245
+ ),
246
+ "has_business_logic": bool(
247
+ str(payload.get("business_logic") or "").strip()
248
+ ),
249
+ },
250
+ )
251
+
252
+
215
253
  def get_sql_generation_prompt_compiler() -> MetadataSqlGenerationPromptCompiler | None:
216
254
  prompt_template = get_active_prompt_template_safe("sql_generation")
217
255
 
@@ -263,3 +301,14 @@ def get_result_classification_prompt_compiler() -> (
263
301
  return None
264
302
 
265
303
  return MetadataResultClassificationPromptCompiler(prompt_template=prompt_template)
304
+
305
+
306
+ def get_answer_explanation_prompt_compiler() -> (
307
+ MetadataAnswerExplanationPromptCompiler | None
308
+ ):
309
+ prompt_template = get_active_prompt_template_safe("answer_explanation")
310
+
311
+ if prompt_template is None:
312
+ return None
313
+
314
+ return MetadataAnswerExplanationPromptCompiler(prompt_template=prompt_template)
@@ -2367,6 +2367,7 @@ def get_datasource_schema_contexts_safe(
2367
2367
  for datasource_id in datasource_ids
2368
2368
  if (connector := get_datasource_connector_by_key(session, datasource_id))
2369
2369
  is not None
2370
+ and connector.active
2370
2371
  and not is_system_datasource_connector(connector)
2371
2372
  ]
2372
2373
  else: