Write new secret manager using existing RSA logic

This commit is contained in:
2025-06-22 17:17:56 +02:00
parent 5985a726e3
commit 82ec7fabb4
34 changed files with 2042 additions and 640 deletions

View File

@ -0,0 +1,33 @@
"""Implement db structures for internal password manager
Revision ID: 1657c5d25d2c
Revises: b4e135ff347a
Create Date: 2025-06-21 07:22:17.792528
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1657c5d25d2c'
down_revision: Union[str, None] = 'b4e135ff347a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('client', sa.Column('is_system', sa.Boolean(), nullable=False, default=False, server_default="0"))
op.add_column('client_secret', sa.Column('is_system', sa.Boolean(), nullable=False, default=False, server_default="0"))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('client_secret', 'is_system')
op.drop_column('client', 'is_system')

View File

@ -0,0 +1,44 @@
"""Remove secret key from password database
Revision ID: 71f7272a6ee1
Revises: 1657c5d25d2c
Create Date: 2025-06-22 18:42:53.207334
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '71f7272a6ee1'
down_revision: Union[str, None] = '1657c5d25d2c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('managed_secret')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('managed_secret',
sa.Column('id', sa.CHAR(length=32), nullable=False),
sa.Column('name', sa.VARCHAR(), nullable=False),
sa.Column('description', sa.VARCHAR(), nullable=True),
sa.Column('secret', sa.VARCHAR(), nullable=False),
sa.Column('client_id', sa.CHAR(length=32), nullable=True),
sa.Column('deleted', sa.BOOLEAN(), nullable=False),
sa.Column('created_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('deleted_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###

View File

@ -107,8 +107,7 @@ class ClientOperations:
return ClientView.from_client(db_client)
async def create_client(
self,
create_model: ClientCreate,
self, create_model: ClientCreate, system_client: bool = False
) -> ClientView:
"""Create a new client."""
existing_id = await self.get_client_id(FlexID.name(create_model.name))
@ -117,6 +116,15 @@ class ClientOperations:
status_code=400, detail="Error: A client already exists with this name."
)
client = create_model.to_client()
if system_client:
statement = query_active_clients().where(Client.is_system.is_(True))
results = await self.session.scalars(statement)
other_system_clients = results.all()
if other_system_clients:
raise HTTPException(
status_code=400, detail="Only one system client may exist"
)
client.is_system = True
self.session.add(client)
await self.session.flush()
await self.session.commit()
@ -246,6 +254,15 @@ class ClientOperations:
return ClientPolicyView.from_client(db_client)
async def get_system_client(self) -> ClientView:
"""Get the system client, if it exists."""
statement = query_active_clients().where(Client.is_system.is_(True))
result = await self.session.scalars(statement)
client = result.first()
if not client:
raise HTTPException(status_code=404, detail="No system client registered")
return ClientView.from_client(client)
def resolve_order(statement: Select[Any], order_by: str, reversed: bool) -> Select[Any]:
"""Resolve ordering."""
@ -261,12 +278,13 @@ def resolve_order(statement: Select[Any], order_by: str, reversed: bool) -> Sele
statement = statement.order_by(column.desc())
else:
statement = statement.order_by(column.asc())
#FIXME: Remove
# FIXME: Remove
LOG.info("Ordered by %s (%r)", order_by, reversed)
return statement
LOG.warning("Unsupported order field: %s", order_by)
return statement
def filter_client_statement(
statement: Select[Any], params: ClientListParams, ignore_limits: bool = False
) -> Select[Any]:
@ -299,6 +317,7 @@ async def get_clients(
.select_from(Client)
.where(Client.is_deleted.is_not(True))
.where(Client.is_active.is_not(False))
.where(Client.is_system.is_not(True))
)
count_statement = cast(
Select[tuple[int]],
@ -307,7 +326,8 @@ async def get_clients(
total_results = (await session.scalars(count_statement)).one()
statement = filter_client_statement(query_active_clients(), filter_query, False)
statement = query_active_clients().where(Client.is_system.is_not(True))
statement = filter_client_statement(statement, filter_query, False)
results = await session.scalars(statement)
remainder = total_results - filter_query.offset - filter_query.limit

View File

@ -46,6 +46,25 @@ def create_client_router(get_db_session: AsyncDBSessionDep) -> APIRouter:
client_op = ClientOperations(session, request)
return await client_op.create_client(client)
@router.get("/internal/system_client/", include_in_schema=False)
async def get_system_client(
request: Request,
session: Annotated[AsyncSession, Depends(get_db_session)],
) -> ClientView:
"""Get the system client."""
client_op = ClientOperations(session, request)
return await client_op.get_system_client()
@router.post("/internal/system_client/", include_in_schema=False)
async def create_system_client(
request: Request,
client: ClientCreate,
session: Annotated[AsyncSession, Depends(get_db_session)],
) -> ClientView:
"""Create system client."""
client_op = ClientOperations(session, request)
return await client_op.create_client(client, system_client=True)
@router.get("/clients/{client_identifier}")
async def fetch_client_by_name(
request: Request,

View File

@ -242,7 +242,7 @@ async def resolve_client_secret_clients(
# Ensure we don't create the object before we have at least one client.
clients = ClientSecretDetailList(name=name)
clients.ids.append(str(client_secret.id))
if client_secret.client:
if client_secret.client and not client_secret.client.is_system:
clients.clients.append(
ClientReference(
id=str(client_secret.client.id), name=client_secret.client.name

View File

@ -110,7 +110,6 @@ def get_async_engine(url: URL, echo: bool = False, **engine_kwargs: str) -> Asyn
"""Get an async engine."""
engine = create_async_engine(url, echo=echo, **engine_kwargs)
if url.drivername.startswith("sqlite+"):
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(
dbapi_connection: sqlite3.Connection, _connection_record: object

View File

@ -67,6 +67,7 @@ class Client(Base):
is_active: Mapped[bool] = mapped_column(sa.Boolean, default=True)
is_deleted: Mapped[bool] = mapped_column(sa.Boolean, default=False)
is_system: Mapped[bool] = mapped_column(sa.Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
@ -141,6 +142,8 @@ class ClientSecret(Base):
client: Mapped[Client] = relationship(back_populates="secrets")
deleted: Mapped[bool] = mapped_column(default=False)
is_system: Mapped[bool] = mapped_column(sa.Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
)