diff --git a/docs/reference/feature-flags.md b/docs/reference/feature-flags.md index 2883852..9a00acc 100644 --- a/docs/reference/feature-flags.md +++ b/docs/reference/feature-flags.md @@ -54,12 +54,24 @@ Accepts `EvaluationContext` objects or plain dicts (`{"targeting_key": user_id, --- -### `await engine.save_flag(flag)` +### `await engine.save_flag(flag, *, actor, platform, action, audit)` -Create or replace a flag. +Create or replace a flag. Writes an audit log entry unless `audit=False`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `flag` | `FeatureFlag` | required | Flag to persist | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log (`"dashboard"`, `"cli"`, etc.) | +| `action` | `str \| None` | `None` | Override the audit action string. Defaults to `flag_created` or `flag_updated` based on whether the flag already existed | +| `audit` | `bool` | `True` | Set to `False` to skip writing an audit log entry. Use this for startup seeds and programmatic initialization | ```python +# Normal save — audited await engine.save_flag(FeatureFlag(key="my-flag", ...)) + +# Startup seed — no audit entry +await engine.save_flag(FeatureFlag(key="my-flag", ...), audit=False) ``` --- @@ -76,15 +88,34 @@ Return all flags as a list. --- -### `await engine.delete_flag(key)` +### `await engine.delete_flag(key, *, actor, platform, audit)` -Delete a flag. +Delete a flag. Writes an audit log entry unless `audit=False`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `key` | `str` | required | Key of the flag to delete | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log | +| `audit` | `bool` | `True` | Set to `False` to skip the audit entry | --- -### `await engine.save_segment(segment)` +### `await engine.save_segment(segment, *, actor, platform, audit)` + +Create or replace a segment. Writes an audit log entry unless `audit=False`. -Create or replace a segment. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `segment` | `Segment` | required | Segment to persist | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log | +| `audit` | `bool` | `True` | Set to `False` to skip writing an audit log entry. Use this for startup seeds and programmatic initialization | + +```python +# Startup seed — no audit entry +await engine.save_segment(Segment(key="beta-users", ...), audit=False) +``` --- @@ -100,9 +131,16 @@ Return all segments as a list. --- -### `await engine.delete_segment(key)` +### `await engine.delete_segment(key, *, actor, platform, audit)` + +Delete a segment. Writes an audit log entry unless `audit=False`. -Delete a segment. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `key` | `str` | required | Key of the segment to delete | +| `actor` | `str` | `"system"` | Identity recorded in the audit log | +| `platform` | `str` | `""` | Surface recorded in the audit log | +| `audit` | `bool` | `True` | Set to `False` to skip the audit entry | --- diff --git a/docs/tutorial/feature-flags.md b/docs/tutorial/feature-flags.md index d1e58a6..ed1e812 100644 --- a/docs/tutorial/feature-flags.md +++ b/docs/tutorial/feature-flags.md @@ -84,6 +84,16 @@ enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx) Rollout weights are integers out of `100_000`. The above gives exactly 20% to `"on"` and 80% to `"off"`. Bucketing is deterministic: the same `user_id` always lands in the same bucket. +!!! tip "Seeding flags at startup" + When pre-loading flags in a lifespan or startup function, pass `audit=False` so these programmatic writes do not appear in the audit log: + ```python + @asynccontextmanager + async def lifespan(_): + await engine.save_flag(FeatureFlag(key="new-checkout", ...), audit=False) + yield + ``` + Flags created or updated through the dashboard, REST API, or CLI always audit regardless of this parameter. + --- ## Flag types @@ -260,6 +270,8 @@ await engine.save_segment(Segment( )) ``` +Pass `audit=False` when seeding segments at startup, same as with flags. + ### Segment evaluation order For a given context key `k`: diff --git a/examples/fastapi/feature_flags.py b/examples/fastapi/feature_flags.py index 207f80d..752505e 100644 --- a/examples/fastapi/feature_flags.py +++ b/examples/fastapi/feature_flags.py @@ -131,7 +131,8 @@ async def _seed_flags() -> None: RolloutVariation(variation="off", weight=80_000), ], targets={"on": ["beta_tester_1", "beta_tester_2"]}, - ) + ), + audit=False, ) # ------------------------------------------------------------------ @@ -166,7 +167,8 @@ async def _seed_flags() -> None: variation="dark", ) ], - ) + ), + audit=False, ) # ------------------------------------------------------------------ @@ -200,7 +202,8 @@ async def _seed_flags() -> None: variation="large", ) ], - ) + ), + audit=False, ) # ------------------------------------------------------------------ @@ -241,7 +244,8 @@ async def _seed_flags() -> None: variation="eu", ), ], - ) + ), + audit=False, ) # ------------------------------------------------------------------ @@ -280,7 +284,8 @@ async def _seed_flags() -> None: ], off_variation="v1", fallthrough="v1", - ) + ), + audit=False, ) diff --git a/shield/admin/api.py b/shield/admin/api.py index a1e39f0..a30495a 100644 --- a/shield/admin/api.py +++ b/shield/admin/api.py @@ -936,7 +936,7 @@ async def create_flag(request: Request) -> JSONResponse: if existing is not None: return _err(f"Flag '{flag.key}' already exists. Use PUT to update.", 409) - await _engine(request).save_flag(flag) + await _engine(request).save_flag(flag, actor=_actor(request), platform=_platform(request)) return JSONResponse(flag.model_dump(mode="json"), status_code=201) @@ -964,7 +964,7 @@ async def update_flag(request: Request) -> JSONResponse: except Exception as exc: return _err(f"Invalid flag definition: {exc}") - await _engine(request).save_flag(flag) + await _engine(request).save_flag(flag, actor=_actor(request), platform=_platform(request)) return JSONResponse(flag.model_dump(mode="json")) @@ -1005,7 +1005,7 @@ async def patch_flag(request: Request) -> JSONResponse: if isinstance(updated.fallthrough, str) and updated.fallthrough not in variation_names: return _err(f"fallthrough '{updated.fallthrough}' does not match any variation name") - await _engine(request).save_flag(updated) + await _engine(request).save_flag(updated, actor=_actor(request), platform=_platform(request)) return JSONResponse(updated.model_dump(mode="json")) @@ -1018,7 +1018,9 @@ async def enable_flag(request: Request) -> JSONResponse: if flag is None: return _err(f"Flag '{key}' not found", 404) flag = flag.model_copy(update={"enabled": True}) - await _engine(request).save_flag(flag) + await _engine(request).save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_enabled" + ) return JSONResponse(flag.model_dump(mode="json")) @@ -1031,7 +1033,9 @@ async def disable_flag(request: Request) -> JSONResponse: if flag is None: return _err(f"Flag '{key}' not found", 404) flag = flag.model_copy(update={"enabled": False}) - await _engine(request).save_flag(flag) + await _engine(request).save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_disabled" + ) return JSONResponse(flag.model_dump(mode="json")) @@ -1043,7 +1047,7 @@ async def delete_flag(request: Request) -> JSONResponse: existing = await _engine(request).get_flag(key) if existing is None: return _err(f"Flag '{key}' not found", 404) - await _engine(request).delete_flag(key) + await _engine(request).delete_flag(key, actor=_actor(request), platform=_platform(request)) return JSONResponse({"ok": True, "deleted": key}) @@ -1144,7 +1148,7 @@ async def create_segment(request: Request) -> JSONResponse: if existing is not None: return _err(f"Segment '{segment.key}' already exists. Use PUT to update.", 409) - await _engine(request).save_segment(segment) + await _engine(request).save_segment(segment, actor=_actor(request), platform=_platform(request)) return JSONResponse(segment.model_dump(mode="json"), status_code=201) @@ -1171,7 +1175,7 @@ async def update_segment(request: Request) -> JSONResponse: except Exception as exc: return _err(f"Invalid segment definition: {exc}") - await _engine(request).save_segment(segment) + await _engine(request).save_segment(segment, actor=_actor(request), platform=_platform(request)) return JSONResponse(segment.model_dump(mode="json")) @@ -1183,5 +1187,5 @@ async def delete_segment(request: Request) -> JSONResponse: existing = await _engine(request).get_segment(key) if existing is None: return _err(f"Segment '{key}' not found", 404) - await _engine(request).delete_segment(key) + await _engine(request).delete_segment(key, actor=_actor(request), platform=_platform(request)) return JSONResponse({"ok": True, "deleted": key}) diff --git a/shield/cli/main.py b/shield/cli/main.py index f27cf5e..3d306e5 100644 --- a/shield/cli/main.py +++ b/shield/cli/main.py @@ -664,12 +664,28 @@ async def _run_log() -> None: "rl_policy_updated": "update", "rl_reset": "reset", "rl_policy_deleted": "delete", + "flag_created": "created", + "flag_updated": "updated", + "flag_enabled": "enabled", + "flag_disabled": "disabled", + "flag_deleted": "deleted", + "segment_created": "created", + "segment_updated": "updated", + "segment_deleted": "deleted", } _rl_action_colours = { "rl_policy_set": "green", "rl_policy_updated": "yellow", "rl_reset": "cyan", "rl_policy_deleted": "red", + "flag_created": "green", + "flag_updated": "yellow", + "flag_enabled": "green", + "flag_disabled": "dim", + "flag_deleted": "red", + "segment_created": "green", + "segment_updated": "yellow", + "segment_deleted": "red", } table = Table(box=box.ROUNDED, show_header=True, header_style="bold") diff --git a/shield/core/engine.py b/shield/core/engine.py index 155869d..9a7ebcc 100644 --- a/shield/core/engine.py +++ b/shield/core/engine.py @@ -681,17 +681,46 @@ async def get_flag(self, key: str) -> Any: flags = await self.backend.load_all_flags() return next((f for f in flags if f.key == key), None) - async def save_flag(self, flag: Any) -> None: + async def save_flag( + self, + flag: Any, + actor: str = "system", + platform: str = "", + action: str | None = None, + audit: bool = True, + ) -> None: """Persist *flag* to the backend and update the provider cache.""" + existing = await self.get_flag(flag.key) + default_action = "flag_created" if existing is None else "flag_updated" await self.backend.save_flag(flag) if self._flag_provider is not None: self._flag_provider.upsert_flag(flag) + if audit: + await self._audit_rl( + path=f"flag:{flag.key}", + action=action or default_action, + actor=actor, + platform=platform, + ) - async def delete_flag(self, key: str) -> None: + async def delete_flag( + self, + key: str, + actor: str = "system", + platform: str = "", + audit: bool = True, + ) -> None: """Delete a flag by *key* from the backend and provider cache.""" await self.backend.delete_flag(key) if self._flag_provider is not None: self._flag_provider.delete_flag(key) + if audit: + await self._audit_rl( + path=f"flag:{key}", + action="flag_deleted", + actor=actor, + platform=platform, + ) async def list_segments(self) -> list[Any]: """Return all segments from the provider cache (or backend).""" @@ -706,17 +735,45 @@ async def get_segment(self, key: str) -> Any: segments = await self.backend.load_all_segments() return next((s for s in segments if s.key == key), None) - async def save_segment(self, segment: Any) -> None: + async def save_segment( + self, + segment: Any, + actor: str = "system", + platform: str = "", + audit: bool = True, + ) -> None: """Persist *segment* to the backend and update the provider cache.""" + existing = await self.get_segment(segment.key) + action = "segment_created" if existing is None else "segment_updated" await self.backend.save_segment(segment) if self._flag_provider is not None: self._flag_provider.upsert_segment(segment) + if audit: + await self._audit_rl( + path=f"segment:{segment.key}", + action=action, + actor=actor, + platform=platform, + ) - async def delete_segment(self, key: str) -> None: + async def delete_segment( + self, + key: str, + actor: str = "system", + platform: str = "", + audit: bool = True, + ) -> None: """Delete a segment by *key* from the backend and provider cache.""" await self.backend.delete_segment(key) if self._flag_provider is not None: self._flag_provider.delete_segment(key) + if audit: + await self._audit_rl( + path=f"segment:{key}", + action="segment_deleted", + actor=actor, + platform=platform, + ) async def _run_global_config_listener(self) -> None: """Background coroutine: invalidate the global config cache on remote changes. diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py index 13a493b..8ffffdb 100644 --- a/shield/dashboard/routes.py +++ b/shield/dashboard/routes.py @@ -1393,7 +1393,9 @@ async def flag_enable(request: Request) -> Response: if flag is None: return HTMLResponse("Flag not found", status_code=404) flag = flag.model_copy(update={"enabled": True}) - await engine.save_flag(flag) + await engine.save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_enabled" + ) return tpl.TemplateResponse( request, "partials/flag_row.html", @@ -1411,7 +1413,9 @@ async def flag_disable(request: Request) -> Response: if flag is None: return HTMLResponse("Flag not found", status_code=404) flag = flag.model_copy(update={"enabled": False}) - await engine.save_flag(flag) + await engine.save_flag( + flag, actor=_actor(request), platform=_platform(request), action="flag_disabled" + ) return tpl.TemplateResponse( request, "partials/flag_row.html", @@ -1423,7 +1427,7 @@ async def flag_delete(request: Request) -> Response: """DELETE /flags/{key} — delete a flag; return empty response (HTMX removes row).""" engine = _engine(request) key = request.path_params["key"] - await engine.delete_flag(key) + await engine.delete_flag(key, actor=_actor(request), platform=_platform(request)) return HTMLResponse("") @@ -1504,7 +1508,7 @@ async def flag_create_form(request: Request) -> Response: fallthrough=fallthrough, enabled=True, ) - await engine.save_flag(flag) + await engine.save_flag(flag, actor=_actor(request), platform=_platform(request)) return tpl.TemplateResponse( request, "partials/flag_row.html", @@ -1611,7 +1615,7 @@ async def flag_settings_save(request: Request) -> Response: name = str(form.get("name", flag.name)).strip() or flag.name description = str(form.get("description", flag.description or "")).strip() updated = flag.model_copy(update={"name": name, "description": description}) - await engine.save_flag(updated) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) _svg = ( "" @@ -1704,7 +1708,7 @@ async def flag_variations_save(request: Request) -> Response: patch["fallthrough"] = variations[0].name updated = flag.model_copy(update=patch) - await engine.save_flag(updated) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) _svg = ( "" @@ -1822,7 +1826,7 @@ async def flag_targeting_save(request: Request) -> Response: ) updated = flag.model_copy(update=patch) - await engine.save_flag(updated) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) _svg = ( "" @@ -1871,7 +1875,7 @@ async def flag_prerequisites_save(request: Request) -> Response: prereqs.append(Prerequisite(flag_key=flag_key, variation=variation)) updated = flag.model_copy(update={"prerequisites": prereqs}) - await engine.save_flag(updated) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) _svg = ( "" @@ -1907,7 +1911,7 @@ async def flag_targets_save(request: Request) -> Response: targets[variation_name] = keys updated = flag.model_copy(update={"targets": targets}) - await engine.save_flag(updated) + await engine.save_flag(updated, actor=_actor(request), platform=_platform(request)) _svg = ( "" @@ -2017,7 +2021,7 @@ async def segment_create_form(request: Request) -> Response: from shield.core.feature_flags.models import Segment segment = Segment(key=key, name=name) - await engine.save_segment(segment) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) return tpl.TemplateResponse( request, "partials/segment_row.html", @@ -2030,7 +2034,7 @@ async def segment_delete(request: Request) -> Response: """DELETE /segments/{key} — delete segment; return empty (HTMX removes row).""" engine = _engine(request) key = request.path_params["key"] - await engine.delete_segment(key) + await engine.delete_segment(key, actor=_actor(request), platform=_platform(request)) return HTMLResponse("") @@ -2051,7 +2055,7 @@ async def segment_save_form(request: Request) -> Response: included = [k.strip() for k in included_raw.splitlines() if k.strip()] excluded = [k.strip() for k in excluded_raw.splitlines() if k.strip()] segment = segment.model_copy(update={"included": included, "excluded": excluded}) - await engine.save_segment(segment) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) return tpl.TemplateResponse( request, "partials/segment_row.html", @@ -2106,7 +2110,7 @@ async def segment_rule_add(request: Request) -> Response: rules = list(segment.rules) + [rule] segment = segment.model_copy(update={"rules": rules}) - await engine.save_segment(segment) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) return tpl.TemplateResponse( request, "partials/segment_rules_section.html", @@ -2127,7 +2131,7 @@ async def segment_rule_delete(request: Request) -> Response: rules = [r for r in segment.rules if r.id != rule_id] segment = segment.model_copy(update={"rules": rules}) - await engine.save_segment(segment) + await engine.save_segment(segment, actor=_actor(request), platform=_platform(request)) return tpl.TemplateResponse( request, "partials/segment_rules_section.html", diff --git a/shield/dashboard/templates/base.html b/shield/dashboard/templates/base.html index ba208d1..3e68572 100644 --- a/shield/dashboard/templates/base.html +++ b/shield/dashboard/templates/base.html @@ -265,16 +265,6 @@ Routes - - - - - - Audit Log - - + + + + Audit Log + + diff --git a/shield/dashboard/templates/partials/audit_row.html b/shield/dashboard/templates/partials/audit_row.html index ae5a372..e12af89 100644 --- a/shield/dashboard/templates/partials/audit_row.html +++ b/shield/dashboard/templates/partials/audit_row.html @@ -50,7 +50,15 @@ "global_rl_reset": "bg-sky-50 text-sky-700 ring-sky-600/20", "global_rl_deleted": "bg-red-50 text-red-700 ring-red-600/20", "global_rl_enabled": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", - "global_rl_disabled":"bg-slate-100 text-slate-500 ring-slate-400/20" + "global_rl_disabled":"bg-slate-100 text-slate-500 ring-slate-400/20", + "flag_created": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "flag_updated": "bg-amber-50 text-amber-700 ring-amber-600/20", + "flag_enabled": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "flag_disabled": "bg-slate-100 text-slate-500 ring-slate-400/20", + "flag_deleted": "bg-red-50 text-red-700 ring-red-600/20", + "segment_created": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "segment_updated": "bg-amber-50 text-amber-700 ring-amber-600/20", + "segment_deleted": "bg-red-50 text-red-700 ring-red-600/20" } %} {% set rl_action_labels = { "rl_policy_set": "set", @@ -62,7 +70,15 @@ "global_rl_reset": "global reset", "global_rl_deleted": "global delete", "global_rl_enabled": "global enabled", - "global_rl_disabled":"global disabled" + "global_rl_disabled":"global disabled", + "flag_created": "created", + "flag_updated": "updated", + "flag_enabled": "enabled", + "flag_disabled": "disabled", + "flag_deleted": "deleted", + "segment_created": "created", + "segment_updated": "updated", + "segment_deleted": "deleted" } %} {% if entry.action in rl_action_labels %}