Feat: Implemented SUDO Department & API Key, CA Serial

This commit is contained in:
2026-03-08 18:10:26 +05:45
parent ff976ee1cc
commit f334000da3
16 changed files with 911 additions and 5 deletions
@@ -0,0 +1,76 @@
"""Seed CA serial counters with a timestamp-based starting value.
Revision ID: 020_ca_serial_timestamp_start
Revises: 019_audit_varchar, d34bfb72844e
Create Date: 2026-03-06
WHY
---
``next_serial_number`` was originally seeded at ``1`` for every CA
(``server_default="1"`` in migration 017). Because the
``ix_ssh_certificates_serial`` index enforces a globally-unique constraint on
the serial column, any two CAs issuing their first certificate would both try
to insert serial ``1``, causing a UniqueViolation.
FIX — new CAs
-------------
The CA model's Python-side ``default`` is now ``_serial_start()``, which
returns ``int(time.time() * 1000)`` (Unix milliseconds) at row-creation time.
CAs created after this migration will start their serial counter at the
millisecond they were first inserted, so serials are globally unique across
CAs and still monotonically increasing within each CA.
FIX — existing CAs
-------------------
This migration performs a data migration: any CA whose ``next_serial_number``
is still ``<= 2`` (i.e. has issued at most one certificate since the original
``1``-based default) is given a new timestamp-based starting value.
CAs that have already issued many certificates keep their current counter
unchanged — their serials are already beyond the low collision-prone range.
NOTE: the ``server_default`` on the column is intentionally NOT changed here
because SQLAlchemy uses the Python-side ``default=_serial_start`` callable for
new rows; the ``server_default`` is only a database-level fallback that is
never hit when rows are inserted via the ORM.
"""
import time
from alembic import op
import sqlalchemy as sa
revision = "020_ca_serial_timestamp_start"
down_revision = ("3de11c5dc2d5", "d34bfb72844e")
branch_labels = None
depends_on = None
def _now_ms() -> int:
return int(time.time() * 1000)
def upgrade():
conn = op.get_bind()
# Update ALL CAs to a timestamp-based starting serial — not just those
# stuck at 1. Any CA with a serial below the current ms timestamp is in
# the low collision-prone range (serials 1N where N is tiny). Resetting
# every CA to a fresh ms timestamp is safe: the counter only moves forward
# from here, and no existing certificate serial is changed.
rows = conn.execute(
sa.text("SELECT id FROM cas")
).fetchall()
for (ca_id,) in rows:
new_start = _now_ms()
conn.execute(
sa.text(
"UPDATE cas SET next_serial_number = :val WHERE id = :id"
),
{"val": new_start, "id": ca_id},
)
def downgrade():
# There is no safe downgrade for a data migration that assigns new serial
# starting points — resetting to 1 would recreate the collision risk.
pass
@@ -0,0 +1,30 @@
"""add_cert_token_to_ssh_certificates
Revision ID: 3de11c5dc2d5
Revises: 019_audit_varchar
Create Date: 2026-03-06 16:04:33.561099
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3de11c5dc2d5'
down_revision = '019_audit_varchar'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('ssh_certificates', sa.Column('cert_token', sa.String(length=64), nullable=True))
op.create_index(op.f('ix_ssh_certificates_cert_token'), 'ssh_certificates', ['cert_token'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_ssh_certificates_cert_token'), table_name='ssh_certificates')
op.drop_column('ssh_certificates', 'cert_token')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""Add can_sudo column to departments table.
Revision ID: 002_add_can_sudo_to_departments
Revises: 001_add_org_api_keys
Create Date: 2026-03-07 23:40:30.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002_add_can_sudo_to_departments'
down_revision = '001_add_org_api_keys'
branch_labels = None
depends_on = None
def upgrade():
# Add can_sudo column to departments table
op.add_column('departments',
sa.Column('can_sudo', sa.Boolean(), nullable=False, server_default='false'))
# Create index for performance
op.create_index('idx_dept_can_sudo', 'departments',
['organization_id', 'can_sudo'])
def downgrade():
# Drop index
op.drop_index('idx_dept_can_sudo', table_name='departments')
# Drop column
op.drop_column('departments', 'can_sudo')
@@ -0,0 +1,56 @@
"""Add organization_api_keys table for API key management.
Revision ID: 001_add_org_api_keys
Revises: 3de11c5dc2d5
Create Date: 2026-03-07 23:40:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001_add_org_api_keys'
down_revision = '3de11c5dc2d5'
branch_labels = None
depends_on = None
def upgrade():
# Create organization_api_keys table
op.create_table(
'organization_api_keys',
sa.Column('id', sa.String(36), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('organization_id', sa.String(36), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('key_hash', sa.String(255), nullable=False),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_revoked', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoke_reason', sa.String(255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key_hash'),
)
# Create indexes for performance
op.create_index('idx_org_api_key_org_active', 'organization_api_keys',
['organization_id', 'is_revoked'])
op.create_index('idx_api_key_last_used', 'organization_api_keys',
['last_used_at'])
op.create_index('idx_org_api_key_org_id', 'organization_api_keys',
['organization_id'])
def downgrade():
# Drop indexes
op.drop_index('idx_org_api_key_org_id', table_name='organization_api_keys')
op.drop_index('idx_api_key_last_used', table_name='organization_api_keys')
op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys')
# Drop table
op.drop_table('organization_api_keys')