feat: apikey alias/renewal + markdown/patch authorship

- APIKey.alias (unique, required). Creating with an existing alias
  renews that key: same key string kept, validity reset to 15d,
  reactivated, name/roles updated (response has renewed=true).
- get_actor(): X-API-Key -> key alias, Bearer -> 'admin'.
- markdown & patch create/update record author / created_at /
  updated_at / last_modified_by from the actor.
- Idempotent run_migrations() (information_schema-guarded ALTERs +
  backfill) so existing tables/data gain the new columns on startup;
  create_all still covers fresh DBs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 22:51:40 +01:00
parent 9e2477df8c
commit bf4c0dbbbd
8 changed files with 164 additions and 8 deletions

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,