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("