Feat: Implemented SUDO Department & API Key, CA Serial
This commit is contained in:
@@ -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 1–N 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')
|
||||
Reference in New Issue
Block a user