feat(ssh): change SSH key uniqueness to per-user scope

Previously, SSH key fingerprints were globally unique across all users,
preventing the same key from being registered by different users. This
change makes fingerprint uniqueness scoped to individual users.

- Remove global unique constraints on payload and fingerprint columns
- Add composite unique constraint on (user_id, fingerprint)
- Make add_ssh_key operation idempotent for same user
- Return tuple (SSHKey, is_new) from service to indicate creation status
- Update API to return 200 for existing keys, 201 for new keys

BREAKING CHANGE: API behavior changed - duplicate key addition now
returns 200 OK instead of 409 Conflict. Service method signature changed
from returning SSHKey to tuple[SSHKey, bool].
This commit is contained in:
2026-04-25 06:22:08 +09:30
parent cec04f3cb2
commit de6f39e7e3
5 changed files with 132 additions and 38 deletions
+26 -22
View File
@@ -41,46 +41,47 @@ class SSHKeyService:
user_id: str,
public_key: str,
description: Optional[str] = None,
) -> SSHKey:
) -> tuple[SSHKey, bool]:
"""Add an SSH public key for a user.
Args:
user_id: ID of the user
public_key: SSH public key in OpenSSH format
description: Optional description of the key
Returns:
Created SSHKey instance
Tuple of (SSHKey instance, is_new) where is_new is True for
newly created keys, False for existing keys (idempotent).
Raises:
UserNotFoundError: If user doesn't exist
SSHKeyError: If key format is invalid
SSHKeyAlreadyExistsError: If key already exists
"""
# Verify user exists
user = User.query.get(user_id)
if not user:
raise UserNotFoundError(f"User {user_id} not found")
# Validate key format
if not verify_ssh_key_format(public_key):
raise SSHKeyError("Invalid SSH public key format")
# Compute fingerprint
try:
fingerprint = compute_ssh_fingerprint(public_key)
except Exception as e:
logger.error(f"Failed to compute fingerprint: {str(e)}")
raise SSHKeyError(f"Failed to compute key fingerprint: {str(e)}")
# Check for duplicate (including soft-deleted records — fingerprint is unique in DB)
existing = SSHKey.query.filter_by(fingerprint=fingerprint).first()
# Check for duplicate per user (including soft-deleted records)
existing = SSHKey.query.filter_by(
user_id=user_id, fingerprint=fingerprint
).first()
if existing:
if existing.deleted_at is not None:
# Restore the soft-deleted key: clear deleted_at and update fields
existing.deleted_at = None
existing.user_id = user_id
existing.description = description or existing.description
existing.description = description if description is not None else existing.description
existing.verified = False
existing.verified_at = None
existing.verify_text = None
@@ -90,15 +91,18 @@ class SSHKeyService:
f"Restored soft-deleted SSH key for user {user_id}: "
f"fingerprint={fingerprint}"
)
return existing
raise SSHKeyAlreadyExistsError(
f"SSH key with fingerprint {fingerprint} already exists"
return existing, False
# Idempotent: return existing key without error
logger.info(
f"SSH key already exists for user {user_id}: "
f"fingerprint={fingerprint}"
)
return existing, False
# Extract metadata
key_type = extract_ssh_key_type(public_key)
key_comment = extract_ssh_key_comment(public_key)
# Create SSH key record
ssh_key = SSHKey(
user_id=user_id,
@@ -109,15 +113,15 @@ class SSHKeyService:
key_comment=key_comment,
verified=False,
)
ssh_key.save()
logger.info(
f"SSH key added for user {user_id}: "
f"fingerprint={fingerprint}, type={key_type}"
)
return ssh_key
return ssh_key, True
def get_ssh_key(self, key_id: str) -> SSHKey:
"""Get an SSH key by ID.