Skip to content

feat: 노출 식별자를 UUID로 전환 - #47

Merged
yessjun merged 11 commits into
mainfrom
feat/public-uuid-identifiers
Aug 10, 2026
Merged

feat: 노출 식별자를 UUID로 전환#47
yessjun merged 11 commits into
mainfrom
feat/public-uuid-identifiers

Conversation

@yessjun

@yessjun yessjun commented Aug 10, 2026

Copy link
Copy Markdown
Member

📝 작업내용

  • API 경계를 넘는 식별자를 전부 UUID로, 내부 기본 키는 bigint 그대로 둔다
  • 노출되는 테이블 23개에 공개 식별자 컬럼과 유니크 인덱스를 더한다, 대상은 생성 계약에서 도출했다
  • 감사 대상 식별자를 텍스트로 바꿔 공개 식별자를 기록한다, 기존 행의 숫자는 그대로 남는다
  • 기관 슬러그 폐지, 토큰 주체를 공개 식별자로 바꾸고 기관 클레임은 제거한다
  • 앞선 마이그레이션이 남긴 컬럼 코멘트 정정 두 건 동승

⭐️ 검증

  • 마이그레이션을 dev 사본에 앱 롤로 적용해 증명, 23/23 테이블 채움·중복 0·원시 삽입 시 기본값 발동·감사 불변성 유지
  • api verify.sh 통과, 계약에 식별자 모양의 정수 필드 0
  • 검토가 찾은 결함 두 건은 결함 상태에서 실제로 실패하는 것을 확인한 뒤 고쳤다

💬 리뷰 포인트

  • 소유권 비교가 참조 비교로 바뀌어 있었다, 작은 수의 캐시 안에서만 참이라 새 데이터베이스로 도는 테스트는 이 결함을 구조적으로 볼 수 없다
  • 알림 링크를 검증하는 단언이 스위트에 하나도 없었다, 그래서 같은 파일의 두 발행 지점이 갈라진 채 남아 있었다
  • 내부 계약은 숫자를 유지한다, 터미널 브리지와 릴레이는 다른 레포의 클라이언트가 정수로 읽는다

yessjun added 11 commits August 11, 2026 01:31
Sequential ids in URLs disclose the platform's size and its growth rate:
before V78 the number in a VM or domain URL *was* the row count. Each
exposed table gains a public_id column; the bigint primary keys, the
foreign keys and the id-ordered sorts all stay as they were.

Resolution happens at the gates that already own the masking 404 —
VmAccessService, AdminVmAccess, the grant service and the few single
findById sites where a path id first lands — so nothing below a gate
changes and no refusal wording had to be re-picked. A well-formed bigint
in a path is no longer a well-formed id, so /vms/999999 now answers 422
where it answered 404.

The JWT subject becomes the account's public id and the write-only
org_id claim is dropped. audit_logs.target_id records the target's
public id; a refresh token has no public identity, so those rows carry
the internal id in the detail map instead of in the target column.

The organisation slug goes with the numbers (V78 drops the column): it
existed to name an org in a URL, which the public id now does. Its
unique index was also the only duplicate protection POST /admin/orgs
had, so org names may now repeat.
The link path is assembled by string concatenation out of an untyped
argument map, so a job still passing the row number compiled unchanged
and produced a console link that resolves to nothing. Seven of them did:
the expiry notice and auto-stop, the delete-completed notice, the
provision result, and three publishing paths.
The admin list scopes take the org filter as a public id now, and an id
no org has has to read as a filter matching nothing. Writing that as a
conditional mixing long and Long unboxed the null branch, so the user,
workspace and audit listings answered 500 instead of an empty page.
Fixtures keep setting rows up through direct SQL, so they still hold
internal ids; what changed is that a path, a query filter, a request
body and an assertion all name the row by its public id instead. Two
shared helpers carry that: one resolves a row's public id for a fixture
holding the internal one, the other goes back the other way for a
fixture that created its row through the API.

The numeric "does not exist" probes have no UUID equivalent, so they
share one fixed identifier no row can have. audit_logs.target_id is text
holding a public id now, which is what every audit assertion compares
against, and the notification payload assertions follow the same move.

PublicIdentifierTest is new: it pins the boundary itself — an id the API
hands back is a UUID and never the row number, the row number no longer
addresses anything, an unknown id and another workspace's real id are
refused identically, and the access token names its account by public id
and carries no organisation claim.
221 path parameters, query filters and response fields change from
int64 to a uuid-formatted string. Nine fields leave their required
lists: every one is a history-preserving join whose id used to come
from the referring row and now comes from the referenced one, so a
deleted workspace, a withdrawn account or a since-removed catalog row
leaves nothing to report. The organisation slug is gone from the
schemas entirely, as is the org_id claim from the security scheme.
An ORG_ADMIN's org announcement takes its organisation from the actor,
not from the request body, so echoing the body's field back reported a
null organisation on exactly the send that has one. The response now
reads both scope targets off the saved row.

The tests that came with it: the org fixtures key on the name the slug
used to carry, the audit and payload comparisons take the public id as
text, and a fixture that deletes its row reads that row's identifier
before deleting it.
The org fixtures that still keyed on the dropped slug, the notification
payload and audit comparisons that still passed a row number, and the
port-forwarding delete path. The web terminal's force-terminate audit
keys on the session instead: the mirror's VM id belongs to no row in
that test, so the target column is empty by design there.
The nested record kept the internal bigint while its enclosing response had
already moved, so /me and the admin user detail handed out a workspace's
sequential id. The admin page builds a /admin/vms?workspaceId= link from it,
which the server no longer accepts -- a link that compiles and filters on
nothing.
Both operands became Long objects when the lookup moved to the public id, so
== stopped comparing values and started comparing references. It succeeds only
inside the Long cache, which is why every test passed: a fresh test database
issues ids from 1. The dev database has eight keys above 127, each about to
become undeletable with its private key undownloadable.

The new test pushes the sequence past the cache first, and asserts the refusal
the download gives for its own reason rather than the one a failed owner check
gives.
The create path already passed public ids; the status-change path 128 lines
below still passed internal ones, so the stored link pointed at a route that no
longer resolves. Nothing asserted link_path anywhere in the suite, which is how
the two drifted apart.
@yessjun
yessjun merged commit 88c86d8 into main Aug 10, 2026
1 check passed
@yessjun
yessjun deleted the feat/public-uuid-identifiers branch August 10, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant