Publisher identity: app-local tokens, and enforceable namespace ownership
Closes the gap that made section 20.1 ownership advisory. The service could refuse anonymous callers but could not tell two publishers apart, so a closed namespace could be protected and never attributed. Follows DR-3, resolved 2026-07-10: app-local accounts, with platform OIDC demand-gated on client SSO requests, instance consolidation, or local-account toil across more than two apps. None of those triggers has fired here, so this is deliberately not OIDC. Tokens rather than accounts because a registry is consumed by CLIs and agents — no browser, no session, no UI to log into, and a login surface nothing uses is a liability. The whole authentication boundary stays in auth.py, so contract section 2.3 is met and a later OIDC switch is bounded rather than a search. The properties that matter are the ones about what a credential cannot do: - tokens are stored hashed, because a registry that can print its own credentials back is one database read away from impersonating every publisher it knows, and are shown once at creation; - an unknown token and a wrong token get the same answer, so a caller cannot enumerate which tokens exist; - a publisher cannot mint publishers — that would be an administrator with extra steps, and revoking one would no longer revoke what it could do; - the operator token publishes but owns nothing, so it is a bootstrap path rather than an identity that can hold a namespace; - a closed namespace with no owner recorded admits nobody, including the operator: reading a missing owner as "anyone" would invert the point of closing it; - revocation is a timestamp, not a delete, so what someone published stays attributed to them after their credential is withdrawn. Migration 0003 adds publishers and index_entries.published_by. The attribution is a name rather than a foreign key, so deleting a publisher cannot erase the history of what they published. Service tests 49 -> 61. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
parent
f6e20b5e0c
commit
d1631e4eb4
9 changed files with 496 additions and 53 deletions
44
service/migrations/versions/0003_publisher_identity.py
Normal file
44
service/migrations/versions/0003_publisher_identity.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""publisher identity
|
||||
|
||||
App-local publisher tokens (DR-3, resolved 2026-07-10: app-local accounts with
|
||||
platform OIDC demand-gated; business-app-service-contract sections 2.1-2.2).
|
||||
Tokens are stored hashed. Revocation is a timestamp, and index entries record a
|
||||
publisher *name*, so attribution outlives the credential that produced it.
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
BigIntPK = sa.BigInteger().with_variant(sa.Integer, "sqlite")
|
||||
|
||||
revision = '0003'
|
||||
down_revision = '0002'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('publishers',
|
||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False),
|
||||
sa.Column('tenant', sa.String(length=64), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant', 'name', name='uq_publisher_name'),
|
||||
sa.UniqueConstraint('token_hash', name='uq_publisher_token')
|
||||
)
|
||||
op.add_column('index_entries', sa.Column('published_by', sa.String(length=128), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('index_entries', 'published_by')
|
||||
op.drop_table('publishers')
|
||||
# ### end Alembic commands ###
|
||||
Loading…
Add table
Add a link
Reference in a new issue