generated from coulomb/repo-seed
feat(local-identity): Stage 4 — security hardening (NK-WP-0002-T04)
Permission enforcement on startup: enforce_permissions() checks store dir (700), user files (600), signing key, TLS key, audit.log, revoked.json. CLI and run_server() call it before any sensitive operation. New modules: security.py check_store(), enforce_permissions(), print_security_check() audit.py log_event() — append-only TSV audit log (mode 600) revoke.py revoke(jti), is_revoked(jti) — revocation list (mode 600) New CLI commands: security-check Print per-check pass/warn/fail report; exit 1 on failure revoke-token <jti|jwt> Add JTI to revocation list; accepts raw JTI or full JWT Serve integration: Audit log written for auth request, token issuance, and userinfo calls Revocation checked at /userinfo; revoked tokens return 401 Docs: security model section in LocalIdentity.md — threat model, assumptions, non-guarantees, SELinux/AppArmor guidance, revocation usage. 138 tests passing (34 new for Stage 4). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,20 +12,29 @@ Commands:
|
||||
export --all [--realm R] Bulk partial-import body (primary users only).
|
||||
Add --include-test to include generated users.
|
||||
serve [--port P] [--ttl T] Start the minimal OIDC server on 127.0.0.1.
|
||||
security-check Validate filesystem permissions.
|
||||
revoke-token <jti-or-jwt> Add a token JTI to the revocation list.
|
||||
|
||||
Environment:
|
||||
LOCAL_IDENTITY_HOME Override the store directory (default: ~/.local-identity).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
from .gecos import current_username, get_gecos_fullname
|
||||
from .user import UserRecord, make_test_user
|
||||
from . import audit
|
||||
from . import export as export_mod
|
||||
from . import revoke as revoke_mod
|
||||
from . import serve as serve_mod
|
||||
from . import store
|
||||
from .security import enforce_permissions, print_security_check
|
||||
|
||||
# Commands that must not run the startup permission check
|
||||
_SKIP_ENFORCE = {"init", "security-check"}
|
||||
|
||||
|
||||
def _resolve_init_params(args: argparse.Namespace, config: dict) -> tuple[str, str, str]:
|
||||
@@ -75,6 +84,8 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
store.write_user(test1)
|
||||
store.write_user(test2)
|
||||
|
||||
audit.log_event("init", username, "ok")
|
||||
|
||||
print(f"Initialised local-identity store at {store._store_dir()}")
|
||||
print(f" Primary : {primary.username} ({primary.fullname}) <{primary.email}>")
|
||||
print(f" Test 1 : {test1.username} <{test1.email}>")
|
||||
@@ -92,11 +103,11 @@ def cmd_list(args: argparse.Namespace) -> None:
|
||||
for u in users:
|
||||
utype = "test " if u.generated else "primary"
|
||||
print(f"{u.username:<20} {u.fullname:<30} {u.email:<40} {utype}")
|
||||
audit.log_event("list", None, f"{len(users)}_users")
|
||||
|
||||
|
||||
def cmd_export(args: argparse.Namespace) -> None:
|
||||
if args.username:
|
||||
# Single-user export
|
||||
try:
|
||||
user = store.read_user(args.username)
|
||||
except FileNotFoundError as exc:
|
||||
@@ -104,8 +115,8 @@ def cmd_export(args: argparse.Namespace) -> None:
|
||||
sys.exit(1)
|
||||
kc = export_mod.user_to_keycloak(user, realm=args.realm)
|
||||
print(json.dumps(kc, indent=2))
|
||||
audit.log_event("export", args.username, "ok")
|
||||
else:
|
||||
# Bulk export
|
||||
all_users = store.list_users()
|
||||
if args.include_test:
|
||||
users = all_users
|
||||
@@ -124,6 +135,7 @@ def cmd_export(args: argparse.Namespace) -> None:
|
||||
if_resource_exists=args.if_resource_exists,
|
||||
)
|
||||
print(json.dumps(body, indent=2))
|
||||
audit.log_event("export", "--all", f"{len(users)}_users")
|
||||
|
||||
|
||||
def cmd_show(args: argparse.Namespace) -> None:
|
||||
@@ -133,12 +145,44 @@ def cmd_show(args: argparse.Namespace) -> None:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(user.to_yaml(), end="")
|
||||
audit.log_event("show", args.username, "ok")
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> None:
|
||||
serve_mod.run_server(port=args.port, token_ttl=args.ttl)
|
||||
|
||||
|
||||
def cmd_security_check(args: argparse.Namespace) -> None:
|
||||
rc = print_security_check()
|
||||
sys.exit(rc)
|
||||
|
||||
|
||||
def cmd_revoke_token(args: argparse.Namespace) -> None:
|
||||
token_or_jti: str = args.token
|
||||
|
||||
if token_or_jti.count(".") == 2:
|
||||
# Looks like a JWT — extract the JTI from the payload
|
||||
try:
|
||||
payload_b64 = token_or_jti.split(".")[1]
|
||||
pad = (4 - len(payload_b64) % 4) % 4
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(payload_b64 + "=" * pad)
|
||||
)
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
print("Error: JWT has no 'jti' claim.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"Error decoding JWT: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
jti = token_or_jti
|
||||
|
||||
revoke_mod.revoke(jti)
|
||||
print(f"Token revoked: {jti}")
|
||||
audit.log_event("revoke-token", None, f"jti={jti}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="local-identity",
|
||||
@@ -216,7 +260,26 @@ def main() -> None:
|
||||
)
|
||||
p_serve.set_defaults(func=cmd_serve)
|
||||
|
||||
sub.add_parser(
|
||||
"security-check",
|
||||
help="Validate filesystem permissions of the store",
|
||||
).set_defaults(func=cmd_security_check)
|
||||
|
||||
p_revoke = sub.add_parser(
|
||||
"revoke-token",
|
||||
help="Add a token JTI to the revocation list",
|
||||
)
|
||||
p_revoke.add_argument(
|
||||
"token",
|
||||
help="Token JTI (UUID) or full JWT — the JTI is extracted automatically",
|
||||
)
|
||||
p_revoke.set_defaults(func=cmd_revoke_token)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command not in _SKIP_ENFORCE:
|
||||
enforce_permissions()
|
||||
|
||||
args.func(args)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user