Skip to content

Commit 16196da

Browse files
committed
Add support for user-provided SSH public keys
Part-of: #3644
1 parent 48cbbad commit 16196da

11 files changed

Lines changed: 721 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import datetime
2+
import uuid
3+
4+
from dstack._internal.core.models.common import CoreModel
5+
6+
7+
class PublicKeyInfo(CoreModel):
8+
id: uuid.UUID
9+
added_at: datetime.datetime
10+
name: str
11+
type: str
12+
fingerprint: str

src/dstack/_internal/server/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
metrics,
4343
projects,
4444
prometheus,
45+
public_keys,
4546
repos,
4647
runs,
4748
secrets,
@@ -259,6 +260,7 @@ def register_routes(app: FastAPI, ui: bool = True):
259260
app.include_router(exports.project_router)
260261
app.include_router(imports.project_router)
261262
app.include_router(sshproxy.router)
263+
app.include_router(public_keys.router)
262264

263265
@app.exception_handler(ForbiddenError)
264266
async def forbidden_error_handler(request: Request, exc: ForbiddenError):
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Add UserPublicKeyModel
2+
3+
Revision ID: 59e328ced74c
4+
Revises: c1c2ecaee45c
5+
Create Date: 2026-03-24 11:45:13.560594+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
import sqlalchemy_utils
11+
from alembic import op
12+
13+
import dstack._internal.server.models
14+
15+
# revision identifiers, used by Alembic.
16+
revision = "59e328ced74c"
17+
down_revision = "c1c2ecaee45c"
18+
branch_labels = None
19+
depends_on = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table(
25+
"user_public_keys",
26+
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
27+
sa.Column("created_at", dstack._internal.server.models.NaiveDateTime(), nullable=False),
28+
sa.Column("user_id", sqlalchemy_utils.types.uuid.UUIDType(binary=False), nullable=False),
29+
sa.Column("name", sa.String(length=100), nullable=False),
30+
sa.Column("type", sa.String(length=100), nullable=False),
31+
sa.Column("fingerprint", sa.String(length=100), nullable=False),
32+
sa.Column("key", sa.Text(), nullable=False),
33+
sa.ForeignKeyConstraint(
34+
["user_id"],
35+
["users.id"],
36+
name=op.f("fk_user_public_keys_user_id_users"),
37+
ondelete="CASCADE",
38+
),
39+
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_public_keys")),
40+
sa.UniqueConstraint(
41+
"user_id", "fingerprint", name="uq_user_public_keys_user_id_fingerprint"
42+
),
43+
)
44+
# ### end Alembic commands ###
45+
46+
47+
def downgrade() -> None:
48+
# ### commands auto generated by Alembic - please adjust! ###
49+
op.drop_table("user_public_keys")
50+
# ### end Alembic commands ###

src/dstack/_internal/server/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,3 +1115,24 @@ class ExportedFleetModel(BaseModel):
11151115
ForeignKey("fleets.id", ondelete="CASCADE"), index=True
11161116
)
11171117
fleet: Mapped["FleetModel"] = relationship()
1118+
1119+
1120+
class UserPublicKeyModel(BaseModel):
1121+
__tablename__ = "user_public_keys"
1122+
__table_args__ = (
1123+
UniqueConstraint("user_id", "fingerprint", name="uq_user_public_keys_user_id_fingerprint"),
1124+
)
1125+
1126+
id: Mapped[uuid.UUID] = mapped_column(
1127+
UUIDType(binary=False), primary_key=True, default=uuid.uuid4
1128+
)
1129+
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
1130+
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
1131+
user: Mapped["UserModel"] = relationship()
1132+
name: Mapped[str] = mapped_column(String(100))
1133+
type: Mapped[str] = mapped_column(String(100))
1134+
"""`type` is a key type identifier used by OpenSSH, e.g., `ssh-rsa`, `ecdsa-sha2-nistp521`."""
1135+
fingerprint: Mapped[str] = mapped_column(String(100))
1136+
"""`fingerprint` stores a key digest in the format used by OpenSSH: `SHA256:<base64>`."""
1137+
key: Mapped[str] = mapped_column(Text)
1138+
"""`key` stores a public key in the OpenSSH disk (ASCII-armored) format."""
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from dstack._internal.core.models.keys import PublicKeyInfo
7+
from dstack._internal.server.db import get_session
8+
from dstack._internal.server.models import UserModel
9+
from dstack._internal.server.schemas.public_keys import (
10+
AddPublicKeyRequest,
11+
DeletePublicKeysRequest,
12+
)
13+
from dstack._internal.server.security.permissions import Authenticated
14+
from dstack._internal.server.services import public_keys as public_keys_services
15+
from dstack._internal.server.utils.routers import (
16+
CustomORJSONResponse,
17+
get_base_api_additional_responses,
18+
)
19+
20+
router = APIRouter(
21+
prefix="/api/users/public_keys",
22+
tags=["user public keys"],
23+
responses=get_base_api_additional_responses(),
24+
)
25+
26+
27+
@router.post("/list", response_model=list[PublicKeyInfo])
28+
async def list_user_public_keys(
29+
session: Annotated[AsyncSession, Depends(get_session)],
30+
user: Annotated[UserModel, Depends(Authenticated())],
31+
):
32+
public_keys = await public_keys_services.list_user_public_keys(session=session, user=user)
33+
return CustomORJSONResponse(public_keys)
34+
35+
36+
@router.post("/add", response_model=PublicKeyInfo)
37+
async def add_user_public_key(
38+
body: AddPublicKeyRequest,
39+
session: Annotated[AsyncSession, Depends(get_session)],
40+
user: Annotated[UserModel, Depends(Authenticated())],
41+
):
42+
public_key = await public_keys_services.add_user_public_key(
43+
session=session, user=user, key=body.key, name=body.name
44+
)
45+
return CustomORJSONResponse(public_key)
46+
47+
48+
@router.post("/delete")
49+
async def delete_user_public_keys(
50+
body: DeletePublicKeysRequest,
51+
session: Annotated[AsyncSession, Depends(get_session)],
52+
user: Annotated[UserModel, Depends(Authenticated())],
53+
):
54+
await public_keys_services.delete_user_public_keys(session=session, user=user, ids=body.ids)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import uuid
2+
from typing import Optional
3+
4+
from dstack._internal.core.models.common import CoreModel
5+
6+
7+
class AddPublicKeyRequest(CoreModel):
8+
key: str
9+
name: Optional[str] = None
10+
11+
12+
class DeletePublicKeysRequest(CoreModel):
13+
ids: list[uuid.UUID]

0 commit comments

Comments
 (0)