diff --git a/api/__init__.py b/api/__init__.py index ac52bd1..327c95f 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -223,4 +223,25 @@ def update_last_used(api_key): session.query(APIKey).filter_by(key=api_key.key).update( {APIKey.last_used_at: datetime.now(UTC)} ) - session.commit() \ No newline at end of file + session.commit() + + +def get_actor(): + """Identity string to record as author/last_modified_by. + + - X-API-Key request -> the key's alias + - Keycloak Bearer request -> the literal 'admin' (the backend does not + track individual KC identities) + - otherwise -> None + Call only from endpoints already behind @require_auth. + """ + api_key_header = request.headers.get('X-API-Key') + if api_key_header: + api_key = get_api_key(api_key_header) + if api_key: + return api_key.alias + return None + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer'): + return 'admin' + return None \ No newline at end of file diff --git a/api/apikey/__init__.py b/api/apikey/__init__.py index 359be27..688ae91 100644 --- a/api/apikey/__init__.py +++ b/api/apikey/__init__.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta, UTC from flask import Blueprint, request, jsonify from api import generate_api_key from db import get_db @@ -10,13 +11,31 @@ api_key_bp = Blueprint('apikey', __name__, url_prefix='/api/apikey') # product defines, regardless of what the request body asks for. ALLOWED_API_KEY_ROLES = {'admin', 'creator', 'user'} +# Validity window applied on create and on every renewal. +KEY_TTL = timedelta(days=15) + @api_key_bp.route('/', methods=['POST']) @require_auth(roles=['admin']) def create_key(): - data = request.get_json(silent=True) + """Create an API key, or renew an existing one. - if not data or 'name' not in data: + `alias` is required and unique. Creating with an alias that already + exists is treated as a RENEWAL of that key: the same key string is + kept (so existing integrations keep working), its validity window is + reset, it is reactivated, and name/roles are updated. The (unchanged) + key string is returned again. + """ + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "invalid or missing JSON body"}), 400 + + alias = data.get('alias') + name = data.get('name') + if not alias or not str(alias).strip(): + return jsonify({"error": "alias is required"}), 400 + if not name: return jsonify({"error": "Name is required"}), 400 + alias = str(alias).strip() roles = data.get('roles', []) if not isinstance(roles, list) or any(r not in ALLOWED_API_KEY_ROLES for r in roles): @@ -24,10 +43,29 @@ def create_key(): try: with get_db() as session: - apikey = APIKey(key=generate_api_key(), name=data['name'], roles=roles) + existing = session.query(APIKey).filter_by(alias=alias).first() + if existing is not None: + # Renewal: keep the key string, reset validity, reactivate. + existing.name = name + existing.roles = roles + existing.is_active = True + existing.expire = datetime.now(UTC) + KEY_TTL + session.commit() + result = existing.to_dict() + result['renewed'] = True + return jsonify(result), 200 + + apikey = APIKey( + key=generate_api_key(), + alias=alias, + name=name, + roles=roles, + expire=datetime.now(UTC) + KEY_TTL, + ) session.add(apikey) session.commit() result = apikey.to_dict() + result['renewed'] = False return jsonify(result), 201 except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/api/markdown.py b/api/markdown.py index 01c0ea1..d9a7a16 100644 --- a/api/markdown.py +++ b/api/markdown.py @@ -1,8 +1,9 @@ from flask import Blueprint, request, jsonify from sqlalchemy import or_ from api import limiter -from api import require_auth, etag_response, verify_token, is_user_admin +from api import require_auth, etag_response, verify_token, is_user_admin, get_actor from contexts.RequestContext import RequestContext +from datetime import datetime, UTC from db import get_db from db.models.Markdown import Markdown from db.models.MarkdownSetting import MarkdownSetting @@ -225,7 +226,13 @@ def create_markdown(): setting_id = data.get('setting_id', None) if not title or not content: return jsonify({"error": "missing required fields"}), 400 - new_markdown = Markdown(title=title, content=content, path_id=path_id, shortcut=shortcut, setting_id=setting_id) + actor = get_actor() + now = datetime.now(UTC) + new_markdown = Markdown( + title=title, content=content, path_id=path_id, shortcut=shortcut, + setting_id=setting_id, author=actor, last_modified_by=actor, + created_at=now, updated_at=now, + ) with get_db() as session: try: if shortcut != "": @@ -301,6 +308,8 @@ def update_markdown(markdown_id): markdown.shortcut = data.get('shortcut') if 'setting_id' in data: markdown.setting_id = data.get('setting_id') + markdown.updated_at = datetime.now(UTC) + markdown.last_modified_by = get_actor() session.commit() markdown_updated.send(None, payload=markdown.to_dict()) return jsonify(markdown.to_dict()), 200 diff --git a/api/patch.py b/api/patch.py index 0db3dd5..569add2 100644 --- a/api/patch.py +++ b/api/patch.py @@ -1,5 +1,6 @@ +from datetime import datetime, UTC from flask import Blueprint, request, jsonify -from api import limiter, require_auth, is_user_admin +from api import limiter, require_auth, is_user_admin, get_actor from contexts.RequestContext import RequestContext from db import get_db from db.models.Markdown import Markdown @@ -65,11 +66,17 @@ def create_patch(): if session.query(Markdown).get(markdown_id) is None: return jsonify({"error": "markdown not found"}), 404 try: + actor = get_actor() + now = datetime.now(UTC) patch = MarkdownPatch( markdown_id=markdown_id, title=data.get('title'), content=content, order=data.get('order', 0), + author=actor, + last_modified_by=actor, + created_at=now, + updated_at=now, ) session.add(patch) session.commit() @@ -104,6 +111,8 @@ def update_patch(patch_id): patch.content = data.get('content') if 'order' in data: patch.order = data.get('order') + patch.updated_at = datetime.now(UTC) + patch.last_modified_by = get_actor() session.commit() return jsonify(patch.to_dict()), 200 diff --git a/db/__init__.py b/db/__init__.py index 013b5c1..a08e94e 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -59,12 +59,71 @@ def init_payload(): session.commit() +def _column_exists(conn, table, column): + row = conn.execute(text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_schema = :db AND table_name = :t AND column_name = :c" + ), {"db": DB_NAME, "t": table, "c": column}).first() + return row is not None + + +def _index_exists(conn, table, index): + row = conn.execute(text( + "SELECT 1 FROM information_schema.statistics " + "WHERE table_schema = :db AND table_name = :t AND index_name = :i" + ), {"db": DB_NAME, "t": table, "i": index}).first() + return row is not None + + +def run_migrations(): + """Idempotent additive schema migrations for already-existing tables. + + create_all() creates missing tables (with the new columns) for a fresh + DB, but never alters existing ones. This adds the new columns to legacy + tables and backfills sensible defaults. Safe to run on every startup. + """ + # (table, column, DDL, backfill SQL or None) + steps = [ + ("apikey", "alias", "ALTER TABLE apikey ADD COLUMN alias VARCHAR(255) NULL", + "UPDATE apikey SET alias = `key` WHERE alias IS NULL"), + ("markdown", "updated_at", "ALTER TABLE markdown ADD COLUMN updated_at DATETIME NULL", + "UPDATE markdown SET updated_at = created_at WHERE updated_at IS NULL"), + ("markdown", "author", "ALTER TABLE markdown ADD COLUMN author VARCHAR(255) NULL", + "UPDATE markdown SET author = 'admin' WHERE author IS NULL"), + ("markdown", "last_modified_by", "ALTER TABLE markdown ADD COLUMN last_modified_by VARCHAR(255) NULL", + "UPDATE markdown SET last_modified_by = 'admin' WHERE last_modified_by IS NULL"), + ("markdown_patch", "author", "ALTER TABLE markdown_patch ADD COLUMN author VARCHAR(255) NULL", + "UPDATE markdown_patch SET author = 'admin' WHERE author IS NULL"), + ("markdown_patch", "last_modified_by", "ALTER TABLE markdown_patch ADD COLUMN last_modified_by VARCHAR(255) NULL", + "UPDATE markdown_patch SET last_modified_by = 'admin' WHERE last_modified_by IS NULL"), + ] + try: + with engine.begin() as conn: + for table, column, ddl, backfill in steps: + if not _column_exists(conn, table, column): + conn.execute(text(ddl)) + if backfill: + conn.execute(text(backfill)) + print(f"[ x ] migrated {table}.{column}") + # Unique constraint on apikey.alias once it is populated. + if not _index_exists(conn, "apikey", "uq_apikey_alias"): + conn.execute(text( + "ALTER TABLE apikey ADD CONSTRAINT uq_apikey_alias UNIQUE (alias)" + )) + print("[ x ] migrated apikey.alias unique constraint") + except Exception as e: + # Don't block startup on a migration hiccup; surface loudly. + print(f"[ ! ] run_migrations error (continuing): {e}") + + def setup_db(): if DB_SCHEMA_UPDATED: clear_db() print("[ x ] db cleared") create_all() print("[ x ] db created") + run_migrations() + print("[ x ] db migrations applied") run_scripts() print("[ x ] db scripts executed") init_payload() diff --git a/db/models/APIKey.py b/db/models/APIKey.py index ea0ebbf..0836e05 100644 --- a/db/models/APIKey.py +++ b/db/models/APIKey.py @@ -6,6 +6,9 @@ class APIKey(Base): __tablename__ = 'apikey' key = Column(String(64), primary_key=True) + # Stable human identity of the key. Unique; creating with an existing + # alias is treated as a renewal of that key (see api/apikey). + alias = Column(String(255), nullable=False, unique=True) name = Column(String(255), nullable=False) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) last_used_at = Column(DateTime) @@ -16,6 +19,7 @@ class APIKey(Base): def to_dict(self): return { "key": self.key, + "alias": self.alias, "name": self.name, "created_at": self.created_at.isoformat() if self.created_at else None, "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, diff --git a/db/models/Markdown.py b/db/models/Markdown.py index 0569f27..78ad4ce 100644 --- a/db/models/Markdown.py +++ b/db/models/Markdown.py @@ -9,7 +9,15 @@ class Markdown(Base): id = Column(Integer, primary_key=True) title = Column(String(255), nullable=False) content = Column(Text, nullable=False) - created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC)) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC)) + updated_at = Column( + DateTime, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ) + # Actor strings: alias of the API key, or 'admin' for KC-logged-in UI. + author = Column(String(255), nullable=True) + last_modified_by = Column(String(255), nullable=True) path_id = Column(Integer, ForeignKey('path.id'), nullable=False) order = Column(String(36), default=lambda: str(uuid.uuid4())) shortcut = Column(String(36), default="") @@ -21,6 +29,9 @@ class Markdown(Base): 'title': self.title, 'content': self.content, 'created_at': self.created_at, + 'updated_at': self.updated_at, + 'author': self.author, + 'last_modified_by': self.last_modified_by, 'path_id': self.path_id, 'order': self.order, 'shortcut': self.shortcut, diff --git a/db/models/MarkdownPatch.py b/db/models/MarkdownPatch.py index 1184dd4..4a98101 100644 --- a/db/models/MarkdownPatch.py +++ b/db/models/MarkdownPatch.py @@ -17,6 +17,9 @@ class MarkdownPatch(Base): ) title = Column(String(255), nullable=True) content = Column(Text, nullable=False) + # Actor strings: alias of the API key, or 'admin' for KC-logged-in UI. + author = Column(String(255), nullable=True) + last_modified_by = Column(String(255), nullable=True) order = Column(Integer, default=0) created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC)) updated_at = Column( @@ -31,6 +34,8 @@ class MarkdownPatch(Base): 'markdown_id': self.markdown_id, 'title': self.title, 'content': self.content, + 'author': self.author, + 'last_modified_by': self.last_modified_by, 'order': self.order, 'created_at': self.created_at, 'updated_at': self.updated_at,