본문으로 건너뛰기

사용자 및 팀

포털은 권한을 조직 하나, 팀 여러 개, 역할 세 가지로 모델링합니다. 모든 사용자는 하나 이상의 팀에 소속되며 프로젝트는 팀에 귀속됩니다. 배포당 정확히 하나의 조직이 존재합니다.

대상 독자

배포를 셋업하는 super-admin; 자기 팀의 멤버십을 관리하는 team admin.

모델​

Organization (배포당 하나)
├── Super Admin — 시스템 전반(install.sh 이후의 본인)
├── Team A
│ ├── Team Admin — 팀 설정·멤버 관리
│ └── Developer — 스캔 실행, 결과 분류
└── Team B
└── ...
  • Organization — 배포의 경계. super-admin은 조직 단위.
  • Team — 프로젝트·스캔·결과가 속하는 단위.
  • User — 이메일 + 비밀번호(또는 데모 SaaS의 OAuth ID)를 가진 사람.

역할​

역할범위권한
super_admin조직 전반모든 admin 화면(/admin/**). 팀 생성·삭제. 모든 프로젝트 편집. 모든 감사 로그 읽기.
team_admin팀별팀 멤버십·설정 관리. 팀 소유 프로젝트 편집. 승인 처리. 팀 API Key 관리.
developer팀별팀 프로젝트 읽기. 프로젝트 생성·편집. 스캔 실행·취소. 결과 분류(VEX 상태). 멤버·설정 관리는 불가.
viewer팀별포털 전반 읽기 전용: 프로젝트, 컴포넌트, 취약점, 라이선스, 의무사항, SBOM, 보고서, 검색, 승인 대기열, 라이선스 정책. 스캔 실행이나 편집은 할 수 없고, 소스 트리·자격 증명·감사 로그에도 접근할 수 없습니다.

역할은 여러 팀에 걸쳐 누적됩니다 — 사용자는 한 팀에서 team_admin이고 다른 팀에서 developer일 수 있습니다. 역할은 프로젝트 소속 팀 기준으로 평가됩니다. 모든 쓰기 동작은 역할이 정확히 일치하는지가 아니라 등급을 기준으로 판단하므로, viewer는 별도의 권한 체계가 아니라 developer보다 한 단계 낮은 등급입니다.

super_admin은 팀별 역할이 아닙니다 — 팀 멤버십과 무관하게 조직 전반 접근을 부여합니다.

Users 페이지​

/admin/users 페이지는 배포 내 모든 계정을 역할 배지, 활성화 상태, 마지막 로그인 시간, 팀 멤버십 카운트와 함께 보여줍니다. 이메일·이름으로 검색하고 역할·상태로 필터링할 수 있습니다.

Admin Users 페이지 — 검색·필터 툴바와 역할·상태 컬럼이 있는 사용자 표

/admin/teams 페이지는 팀 목록과 각 팀이 보유한 프로젝트·멤버 수를 보여줍니다:

Admin Teams 페이지 — 팀별 멤버·프로젝트 카운트

여러 명을 한 번에 추가하기​

/admin/users → 가져오기에 CSV를 넣으면 파일에 있는 사람을 모두 추가하고, 행마다 결과를 알려 줍니다. 첫 줄이 열 이름입니다.

email,full_name,team_id,role
ada@example.com,Ada Lovelace,3f1c...,developer
grace@example.com,Grace Hopper,3f1c...,viewer

필수는 email 하나입니다. role은 team_admin·developer·viewer 중 하나여야 하고, 비워 두면 배포 설정 DEFAULT_MEMBER_ROLE을 따릅니다. 이 값을 지정하지 않았다면 developer입니다. super_admin은 여기서 부여할 수 없습니다.

내보내기는 같은 열을 씁니다. 내려받은 파일을 고쳐서 그대로 다시 넣을 수 있고, 양쪽 어디에도 비밀번호는 담기지 않습니다.

password 열도 받지만 대개 필요하지 않습니다. 통합 인증으로 로그인하는 배포라면 비워 두십시오. 비밀번호가 설정되지 않은 계정으로 만들어지고, 비밀번호 재설정 절차로 누군가 설정하기 전까지는 비밀번호 로그인이 되지 않습니다. 값을 넣으면 가입할 때와 같은 정책으로 검사합니다.

행은 서로 독립적입니다. 이미 계정이 있는 주소 하나가 앞뒤 행을 막지 않고, 결과 표는 성공한 행까지 모두 보여 주므로 빠진 것을 세어 볼 필요가 없습니다.

일괄 비활성화도 같은 방식이고 보호 장치도 그대로입니다. 자기 계정과 마지막 활성 관리자는 비활성화할 수 없으며, 목록에 넣더라도 실패한 행 하나로 돌아올 뿐 전체가 거부되지는 않습니다. 이미 비활성인 사람은 변경 없음으로 표시됩니다. 비활성화하면 그 사람의 세션이 끊기고, 그 사람이 발급한 API 키도 멈춥니다. 서비스 계정의 키는 예외입니다.

자동화가 사람과 함께 멈춥니다

API 키 인증은 발급자가 아직 활성인지 확인합니다. 그래서 사람을 비활성화하면 그 사람이 발급한 키를 쓰던 파이프라인이 함께 멈춥니다. 퇴사 전에 해당 키를 서비스 계정으로 옮기십시오.

새 사용자 온보딩​

v0.10.0 에서는 포털이 초대 이메일을 보내지 않습니다. 새 사용자는 회사 이메일로 /register에서 셀프 가입하며, 비밀번호 정책은 가입 시점에 강제됩니다(12자 이상, bcrypt cost 12, NIST 차단 비밀번호 제외).

가입 후, super_admin이 사용자를 적절한 팀에 추가하고 역할을 배정합니다.

  1. 사용자에게 /register에서 가입을 요청합니다.
  2. /admin/users에 사용자가 나타나면 사용자 드로어를 엽니다.
  3. Add to team(또는 팀의 Members → Add member 흐름)으로 선택한 역할의 팀 멤버십을 부여합니다.

동료 온보딩​

v0.10.0에서 포털은 초대 이메일을 보내지 않습니다. 흐름은 다음과 같습니다:

  1. 관리자가 /admin/teams → New team에서 팀을 만듭니다. (아래 UI 흐름은 team UUID가 필요하지 않습니다 — 관리자는 이메일로 동료를 식별합니다. UUID가 필요한 경로는 스크립트 기반 대량 온보딩뿐이며, GET /v1/admin/teams와 본 섹션 하단의 일괄 레시피를 참고하세요.)
  2. 동료가 https://<your-host>/register에서 셀프 가입합니다.
  3. 관리자가 /admin/users → <user>에서 해당 사용자의 행을 열고 드로어 → Memberships → Add to team으로 팀과 역할을 선택합니다 (기본값으로 developer가 안전).

결과: 동료는 다음 로그인부터 팀의 프로젝트를 볼 수 있습니다. 각 동료가 가입을 마친 뒤에는 POST /v1/admin/teams/{team_id}/members {user_id, role}로 대량 온보딩을 스크립트화할 수 있습니다.

통합 인증 첫 로그인​

포털을 조직의 통합 인증에 연결한 경우, 한 번도 로그인한 적 없는 사람은 기본적으로 계정을 받지 못하고 거부됩니다. 회사 구성원은 모두 인증에 성공할 수 있지만 그중 일부만 포털 계정을 갖도록 정해져 있고, 명단은 관리자가 유지하는 것이기 때문입니다.

AUTH_AUTO_REGISTER=true로 두면 첫 로그인에 계정을 만듭니다. 이때 받는 것은 다음과 같습니다.

  • 계정과 본인 개인 팀
  • 그룹 매핑(OIDC_GROUP_ROLE_MAP)을 설정했다면 그 그룹에 대응하는 등급
  • 매핑은 있는데 그 사람의 그룹이 하나도 맞지 않으면 최소 등급. 그룹을 매핑한 배포는 "어느 그룹에도 속하지 않음"이 무엇을 뜻하는지 이미 말한 셈입니다
  • 매핑 자체가 없으면 DEFAULT_MEMBER_ROLE. 일괄 가져오기가 따르는 설정과 같습니다
명단을 닫으려면 설정 두 개가 필요합니다

AUTH_AUTO_REGISTER=false는 통합 인증 쪽 문만 닫습니다. /register 가입 화면이 또 하나의 문이고 기본값은 열림입니다. 회사 주소로 직접 가입한 다음 통합 인증으로 로그인하면, 콜백이 방금 본인이 만든 계정에 인증 정보를 연결해 줍니다. 결국 앞의 설정이 막으려던 계정을 그대로 갖게 됩니다.

AUTH_SELF_REGISTRATION=false도 함께 지정하십시오. 둘 다 끄면 명단은 관리자가 유지하는 것이 됩니다.

거부 메시지는 다른 로그인 실패와 구분되지 않습니다. 디렉터리에 있는 사람은 누구나 로그인 화면에 닿을 수 있으므로, "인증은 됐지만 계정이 없다"고 알려 주면 어떤 주소가 등록돼 있는지 확인하는 수단이 됩니다.

호스팅 제공자(GitHub, Google)는 이 설정의 영향을 받지 않고 첫 로그인에 계정을 만듭니다. 가입이란 원래 그런 것이기 때문입니다.

접근 권한 변경이 언제 반영되는지​

따로 설정하지 않았다면 즉시입니다. 인증된 요청마다 사용자와 팀 소속을 읽으므로 강등·비활성화·팀 제외는 바로 다음 요청부터 적용됩니다.

PERMISSION_CACHE_TTL_SECONDS는 그 즉시성을 일부 내주고 데이터베이스 조회를 줄입니다. 기본값은 꺼짐입니다. 여기 적은 값이 곧 권한 회수가 반영되지 않을 수 있는 최대 시간입니다. 그 시간 동안은 어떤 워커가 변경 전 등급으로 응답할 수 있습니다.

포털에서 변경하면 캐시가 지워지기는 하지만, 그 요청을 처리한 워커에서만 지워집니다. 배포 기본값은 워커 4개(Docker Compose) 또는 파드 2개 × 워커 4개(Helm)이므로, 강등 직후의 요청 대부분은 여전히 예전 답을 가진 워커로 갑니다. 이 지움은 보조 수단일 뿐이고, 모든 워커를 설명하는 숫자는 수명 하나입니다.

300초를 넘는 값은 300으로 잘립니다. 먼저 손볼 것은 데이터베이스 커넥션 풀(DB_POOL_SIZE, DB_MAX_OVERFLOW)이고, 이 설정은 권한 조회가 실제 비용임을 측정한 뒤에 켤 만합니다.

언제 켤 만한가​

셋이 함께 성립할 때만 켭니다. 하나라도 아니면 0으로 둡니다.

  1. 인증된 읽기의 p95가 목표를 넘습니다.
  2. 연결 풀을 이미 이 배포 규모에 맞게 조정했습니다(DB_POOL_SIZE, DB_MAX_OVERFLOW). 계산식은 .env.example의 안내를 봅니다.
  3. 조직이 위에서 말한 권한 회수 지연 상한을 규정으로 수용했습니다. 우연히 그렇게 된 것이 아니라야 합니다.

셋이 다 성립해도 이 설정이 얻는 것은 크지 않습니다. 인증된 읽기 요청 하나가 실측한 SQL 문장 수는 5개이고, 그중 1개가 로그인한 사용자의 역할과 팀 소속을 다시 만드는 조회입니다(예전에는 같은 조회가 두 문장으로 나뉘어 총 6개였는데, 앞선 개선에서 중복 문장을 없애 5개가 됐습니다). 이 캐시가 없앨 수 있는 것은 남은 그 한 문장뿐이라, 요청 전체 SQL의 약 20%입니다. 그 20%와 위 권한 회수 지연 시간을 저울질한 뒤에 켜십시오.

비활성화가 특히 중요합니다

강등된 사람은 여전히 조회할 수 있습니다. 비활성화된 사람은 접근이 끊겨야 하는데, 수명을 설정해 두면 만료 전까지는 모든 워커에서 끊기지 않습니다. 회수가 즉시 적용돼야 한다면 이 값을 0으로 두십시오. "비활성화됨"과 "아무 데도 닿을 수 없음"이 같은 순간이 되는 설정은 그것뿐입니다.

기존 사용자를 팀에 추가​

사용자는 여러 팀에 소속될 수 있습니다. 기존 사용자 추가:

  1. /admin/teams(super-admin) 또는 Team settings → Members(team admin).
  2. Add member → 이메일로 검색 → 역할 선택.

사용자가 즉시 추가됩니다; 이메일 확인 단계 없음(이미 계정 보유).

사용자 역할 변경​

/admin/users → 사용자 드로어는 Role 드롭다운과 Memberships 섹션을 노출합니다. 한 사용자가 팀마다 다른 역할을 보유할 수 있습니다(팀 A에서 team_admin, 팀 B에서 developer). Memberships 리스트가 모든 배정을 표시하며 인라인으로 편집합니다. Role 드롭다운은 Memberships 리스트에서 선택된 팀의 역할을 설정합니다(또는 super_admin으로 승격할 때의 글로벌 역할).

  1. /admin/users → 사용자 → Role.
  2. 새 역할 선택 → 제출.

감사 로그는 변경을 users 쓰기로 기록하며 역할 diff가 diff 컬럼에 담깁니다(감사 행의 target_table은 users).

팀에서 사용자 제거​

  1. Team settings → Members → 사용자 → Remove.

사용자는 팀의 프로젝트 접근을 잃지만 계정은 남습니다. 계정 자체를 비활성화하려면 비활성화 참고.

마지막 super-admin 보호​

포털은 조직의 마지막 활성 super_admin 강등·비활성화를 거부합니다. 사전 체크는 SELECT … FOR UPDATE 트랜잭션 안에서 실행되어 동시 강등 시도가 경합되지 않고 직렬화됩니다. 시도하면 API가 다음을 반환합니다.

{
"type": "about:blank",
"title": "Last Super Admin Protected",
"status": 422,
"detail": "At least one active super_admin must remain in the organization.",
"instance": "/v1/admin/users/01H…/role",
"last_super_admin_protected": true
}

last_super_admin_protected: true 확장 필드는 클라이언트가 본 가드를 일반적인 422 검증 실패와 구분할 수 있게 합니다.

마지막 super-admin 교체:

  1. 다른 사용자를 super_admin으로 먼저 승격.
  2. 그 다음 원래 사용자를 강등·비활성화.

가드는 두 계층으로 강제됩니다.

  1. API 계층 — admin_user_service의 SELECT … FOR UPDATE 행 락 카운트가 commit 전에 강등·비활성화 시도를 거부.
  2. DB 계층 — PostgreSQL 트리거(trg_last_super_admin, 마이그레이션 0013)가 활성 super-admin 수를 0으로 만드는 모든 UPDATE/DELETE에 대해 SQLSTATE 23514를 발생. 직접 psql 쓰기로 API를 우회하더라도 동일하게 차단됩니다. 어느 계층이 잡았든 동일한 last_super_admin_protected Problem Details 확장 필드가 반환됩니다.

비활성화된 super-admin 복구​

마지막 super-admin 보호 가드에도 불구하고 마지막 super-admin 행의 is_active가 false로 뒤집힌 경우 — 예를 들어 배포 DB 대상 통합 테스트가 deactivate_user를 트립했거나, 다른 super-admin이 교체자를 승격하기 전에 본인을 강등한 경우 — 설치 시 scripts/install.sh가 사용한 부트스트랩 스크립트를 같은 방식으로 다시 실행합니다. 스크립트는 기존 행을 감지해 저장된 비밀번호는 건드리지 않고 is_active 만 다시 true로 올립니다.

docker-compose -f docker-compose.yml exec -T \
-e ADMIN_EMAIL="admin@example.com" \
-e ADMIN_PASSWORD="<기존 비밀번호 — 12자 이상>" \
backend python -m scripts.create_super_admin

스크립트 동작 순서:

  1. ADMIN_EMAIL로 행을 조회.
  2. 행이 존재하고 super_admin이며 비활성 상태이면 is_active를 true로 되돌리고 커밋한 뒤 super admin <email> reactivated를 출력합니다. 저장된 비밀번호 해시는 그대로 둡니다(행이 비활성된 사실만 잊은 경우 재실행이 비파괴적으로 동작하도록).
  3. 행이 존재하고 이미 활성이면 super admin <email> already exists — noop을 출력한 뒤 0으로 종료합니다.
  4. 행이 존재하지만 super_admin이 아니면 에러를 출력한 뒤 비0으로 종료합니다. 재실행 전에 수동으로 승격·교체해야 합니다.
  5. 일치하는 행이 없으면 전달된 비밀번호로 새 super-admin을 생성합니다.
재활성화 경로에서는 비밀번호가 유지됩니다

ADMIN_PASSWORD 값은 행이 생성될 때만 사용됩니다. 재활성화 경로에서는 비밀번호 해시가 그대로 유지되므로 현재 비밀번호를 그대로 전달하면 됩니다(새 비밀번호 아님). 비밀번호도 잊었다면 재활성화로 is_active가 회복된 뒤 비밀번호 재설정 절차를 따르거나, 두 번째 super-admin 계정에서 운영자 측 /admin/users/{id}/password-reset 엔드포인트를 사용하세요.

왜 UI 버튼이 아닌 재실행 방식인가요?

UI에서 마지막 관리자를 재활성화하면 부트스트랩 역설이 발생합니다 — 버튼을 누를 활성 관리자가 없으니까요. 스크립트는 백엔드 컨테이너 안에서 DB 자격증명으로 직접 실행되므로 어떤 super-admin 도 로그인할 수 없는 상황에서도 안전한 복구 해치 역할을 합니다. 동작은 멱등합니다 — 이미 활성인 행에는 영향이 없습니다(no-op).

로그인하지 못하는 사용자 돕기​

/admin/users → 사용자 드로어에 이를 위한 동작이 둘 있습니다. 서로 다른 문제에 대한 답이며, 둘 다 감사 로그에 남습니다.

비밀번호를 확인하기도 전에 거절되는 경우​

로그인 실패 횟수는 이메일 주소 단위로 셉니다. 그래서 본인이 하지 않은 추측 때문에 주소가 거절될 수 있습니다. 로그인 잠금 해제가 그 횟수를 지웁니다.

  1. /admin/users → 사용자 → 로그인 잠금 해제.
  2. 확인합니다.

비밀번호 재설정을 마쳐도 횟수는 지워집니다. 재설정은 비밀번호가 아니라 받은 편지함을 요구하므로, 이 동작은 편지함까지 쓸 수 없게 된 사용자를 위한 것입니다. 다른 것은 건드리지 않습니다. 비밀번호도, 열려 있는 세션도, 2단계 인증도 그대로 남습니다.

인증 앱을 잃어버린 경우​

2단계 인증 해제는 2단계 인증을 쓰는 계정에만 나타납니다. 설정 여부와 저장된 비밀값, 아직 쓰지 않은 복구 코드를 모두 지우고 열려 있던 세션을 끊습니다.

  1. /admin/users → 사용자 → 2단계 인증 해제.
  2. 확인합니다.

해제 사실은 포털 알림으로 사용자에게 전달됩니다. 이 알림이 이 동작을 사후에 확인할 수 있게 만드는 부분입니다. 해제를 요청한 적 없는 사용자가 해제되었다는 사실을 알게 됩니다.

요청이 정말 본인에게서 왔는지를 포털 계정이 아닌 다른 경로로 확인한 뒤에만 실행하세요. 이 동작은 비밀번호를 훔친 사람이 그래도 부딪히게 되는 통제를 없애는 것이고, 그 통제를 없애 달라는 요청이야말로 비밀번호를 훔친 사람이 보낼 내용입니다.

복구 코드가 아직 있는 사용자에게는 관리자가 필요하지 않습니다. 여섯 자리를 묻는 자리에 복구 코드를 입력하면 로그인되고, 그다음 /profile에서 새 묶음을 발급받을 수 있습니다.

사용자 비활성화​

비활성화는 모든 세션과 refresh 토큰을 회수합니다. 사용자는 로그인할 수 없습니다. 감사 로그 항목은 유지(행 추가 전용)됩니다.

  1. /admin/users → 사용자 → Deactivate.
  2. 확인.

같은 화면에서 한 번의 클릭으로 재활성화 가능합니다.

비활성화는 사용 가능한 유일한 오프보딩 동작입니다 — 별도의 사용자 삭제 작업이 없습니다. GDPR 삭제 요청을 처리하려면 사용자를 비활성화한 뒤 엔지니어링 팀에 수동 삭제를 의뢰하세요. 이메일 입력 확인 모달이 있는 1급 소프트-삭제는 로드맵 항목입니다.

팀 생성​

super_admin 전용.

  1. /admin/teams → New team.
  2. 이름·설명·새 프로젝트의 기본 가시성(team_only 또는 org_wide, 선택).
  3. 제출.

팀의 첫 멤버는 다음 화면에서 배정합니다.

중첩 그룹​

한 팀은 그 아래에 다른 팀을 얼마든지 중첩할 수 있습니다. 한 단계가 더 있는 정도가 아니라 그룹 계층 구조입니다. 이 제품에서 "팀"과 "그룹"은 같은 대상을 가리킵니다. 이 문서는 멤버십과 역할을 이야기할 때는 팀이라고 쓰고, 계층 구조에서의 위치, 즉 부모·자식·그 아래 전체 가지를 이야기할 때는 그룹이라고 씁니다. 부모가 없는 그룹은 조직의 루트에 있으며, 중첩이 생기기 전 모든 팀이 있던 자리와 같습니다.

아래 두 동작 모두 super_admin 전용이며, /admin/teams의 그룹 상세 드로어에서 실행합니다.

그룹 이동​

  1. /admin/teams → 그룹 열기 → Move.
  2. New parent → 그룹을 선택하거나, 부모에서 떼어내려면 **Root of organization (no parent)**를 선택합니다.
  3. Move.

전체 하위 트리가 한 번의 작업으로 이동합니다. 이동한 그룹의 모든 하위 그룹은 그 자리를 그대로 유지하고, 이동 지점 위쪽의 조상 체인만 바뀝니다.

New parent 목록은 아래 두 가드레일을 이미 만족하는 그룹만 보여 주므로, 화면 자체가 잘못된 이동을 구성하지 못하게 막습니다.

  • 같은 조직만. 그룹은 자신이 속한 조직 안의 그룹으로만 이동할 수 있습니다.
  • 자기 하위 트리 밑으로는 이동 불가. 그룹은 자기 자신이나 자신의 하위 그룹 밑으로 이동할 수 없습니다. 그렇게 하면 그 그룹이 자기 자신의 조상이 됩니다.

두 가드레일을 어떻게든 건너뛰고 요청이 API에 직접 도달해도 서버는 여전히 거부합니다. 그룹을 자기 자신이나 자신의 하위 그룹 밑으로 이동하면 409(Group Cycle Detected, 확장 필드 cycle_detected: true)가 반환됩니다.

다른 조직에 속한 그룹 밑으로 이동하면 422(Cross-Organization Move Not Allowed, 확장 필드 cross_organization_move: true)가 반환됩니다. 일반적인 배포에는 조직이 정확히 하나뿐이므로 대상 그룹이 속할 다른 조직 자체가 없습니다. 이 검사는 일반적인 관리 작업으로는 걸릴 일이 없는 구조적 안전장치입니다.

하위 그룹 만들기​

  1. /admin/teams → 부모 그룹 열기 → Add subgroup.
  2. 이름, slug, 설명(선택).
  3. Create subgroup.

새 그룹은 부모의 조직을 자동으로 물려받습니다. 루트 팀을 만들 때와 달리 여기에는 조직 선택 항목이 없습니다.

부모의 다른 직계 자식이 이미 쓰는 slug는 아무것도 쓰기 전에 409(Group Slug Conflict)로 거부됩니다.

권한 상속​

그룹의 내용을 자신의 직접 멤버십 목록 밖에서도 볼 수 있는지는 GROUP_CASCADE_ENABLED 설정이 결정하며, 기본값은 켜짐입니다. 설정 자체와 끄는 방법은 .env.example에 있습니다. 켜면 실무에서 바뀌는 점은 다음과 같습니다.

  • 가장 가까운 멤버십이 우선합니다. 어떤 그룹에서 사용자의 유효 역할은 그 그룹에서 루트 쪽으로 올라가며 만나는 가장 가까운 직접 멤버십의 역할입니다. 두 단계 위에서 team_admin을 겸하는 developer도 자신에게 더 가까운 그룹에서는 여전히 developer입니다. 자식 그룹의 강등은 항상 부모의 승격을 덮어쓰고, 그 반대는 성립하지 않습니다.
  • 형제 그룹은 서로 격리됩니다. 한 그룹의 멤버십은 형제 그룹에 대해 아무리 깊어도 아무것도 말해 주지 않습니다. 접근은 하나의 조상 라인을 따라서만 아래로 흐르고, 옆으로는 전혀 흐르지 않습니다.
  • 접근 가능 범위가 하위 트리 전체로 넓어집니다. 어떤 그룹에 직접 멤버십이 있는 사람은 그 아래 전체에 접근할 수 있습니다. 대시보드, 검색, 인벤토리, 스캔 목록, 프로젝트 목록, 라이선스 정책 목록, 컴포넌트 승인 목록까지 모두 그 그룹 하나가 아니라 하위 트리 전체로 넓어집니다.
  • 존재를 숨기는 처리는 그대로 적용됩니다. 사용자의 접근 가능 범위 밖에 있는 그룹은 지금의 플랫 모델과 똑같이 403이 아니라 404를 반환합니다. 눈에 보이는 예외 하나는 그룹 상세 페이지의 조상 브레드크럼입니다. 뷰어가 열 수 없는 조상이라도 이름은 루트까지 전부 표시되는데, 이는 자신이 트리 어디에 있는지 알려 주기 위한 것일 뿐 그 조상을 열게 해 주거나 그 밖의 다른 정보를 알려 주지는 않습니다.
  • super_admin은 이 설정과 무관합니다. 켜든 끄든 게이트 계층에서 플랫 검사와 캐스케이드 검사를 모두 건너뜁니다.

GROUP_CASCADE_ENABLED를 끄면 모든 검사가 중첩이 생기기 전 팀이 가졌던 플랫 동작으로 정확히 돌아갑니다. 사용자의 역할과 접근 가능 범위는 그룹이 어떤 부모에 속해 있든 상관없이 오직 직접 멤버십만으로 결정됩니다.

라이선스·게이트 정책은 별도 방식으로 해석됩니다

프로젝트의 유효 라이선스·게이트 정책은 이미 그룹의 조상을 거슬러 올라가 정책이 설정된 가장 가까운 조상까지 걸어가서 정해집니다. 위의 상속과 같은 최근접 우선 구조이지만, 이 해석은 무조건 실행되며 GROUP_CASCADE_ENABLED와 무관합니다. 이 설정을 끄면 그룹의 내용을 누가 볼 수 있는지가 바뀔 뿐, 프로젝트에 어떤 정책이 적용되는지는 바뀌지 않습니다. 정책 설계 참고.

/groups 화면의 위치​

/admin/teams는 관리 화면입니다. super_admin 전용이며 그룹을 이동하거나 하위 그룹을 만들 수 있는 유일한 곳입니다. /groups는 별도의 읽기 전용 화면으로, 인증된 구성원이라면 누구나 메인 내비게이션에서 들어갈 수 있습니다. 이 화면은 그 사람의 멤버십(직접 또는 캐스케이드로 상속받은)이 허용하는 그룹만 보여 주며, 드릴다운 탐색과 전체 트리를 대상으로 한 평면 이름 검색을 제공합니다.

/groups의 그룹 상세 페이지는 조상 브레드크럼, 직접 멤버와 캐스케이드로 상속받은 멤버를 나눈 두 섹션, 하위 그룹, 프로젝트를 보여 줍니다. 여기에는 이동이나 생성 동작이 없습니다. 그런 동작은 /admin/teams에만 있습니다.

존재하지 않는 그룹이든 호출자가 접근할 수 없는 그룹이든 똑같이 404를 반환합니다.

캐스케이드로만 닿는 그룹에서 새 프로젝트를 만들 때

상단 바의 활성 팀 선택기 하나가 새 프로젝트가 어느 팀 소속으로 만들어질지를 결정합니다. /groups의 그룹 상세 페이지에서 New project를 클릭하면 이 선택기가 캐스케이드로는 보이지만 직접 멤버십은 없는 그룹을 가리키게 될 수 있습니다. 프로젝트 생성 폼은 직접 멤버가 아닌 그룹으로는 제출할 수 없으므로, 다른 팀을 조용히 대신 골라 주는 대신 Active team needs attention이라는 명시적 안내를 보여 줍니다. 상단 바의 팀 전환기로 직접 소속된 팀을 고른 뒤 프로젝트를 만드십시오. 이는 예상된 동작이며 버그가 아닙니다.

팀 이름 변경​

super_admin과 팀의 team_admin은 팀 이름을 변경할 수 있습니다. 팀의 name, slug, description은 PATCH /v1/admin/teams/{team_id}로 변경 가능합니다.

팀 아카이브(새 프로젝트 생성을 차단하면서 기존 프로젝트는 읽기 가능하게 유지하는 숨김 상태)는 로드맵 항목입니다. v0.10.0 에서는 super_admin이 팀 이름을 변경하거나 팀을 삭제할 수 있습니다.

팀 삭제는 가드됩니다: DELETE /v1/admin/teams/{team_id}는 팀에 아카이브되지 않은 프로젝트가 남아 있으면 409(RFC 7807 본문에 team_has_projects: true와 project_count)로 거부되고, 어느 프로젝트든 queued/running 스캔이 있으면 422(team_has_active_scans: true)로 거부됩니다. 먼저 모든 프로젝트를 아카이브한 뒤(Project Settings → Archive) 팀을 삭제하세요. 아카이브된 프로젝트는 삭제를 막지 않습니다 — 라이브 프로젝트가 없으면 팀 삭제가 CASCADE로 그 프로젝트(및 스캔·finding)를 함께 제거합니다.

세션​

토큰수명저장
Access 토큰 (JWT)30분메모리(인앱), Authorization: Bearer ….
Refresh 토큰7일, rotation + 재사용 탐지.HttpOnly + Secure 쿠키, SameSite=Lax.

재사용 탐지: refresh 토큰이 두 번 제시되면 토큰 패밀리 전체가 무효화되어 모든 디바이스에서 재인증을 강제합니다. 이는 refresh 토큰 탈취를 잡습니다.

정상 동작 확인​

사용자 온보딩 후:

  1. 사용자가 가입 시 설정한 비밀번호로 /login에서 로그인 가능.
  1. /admin/users가 사용자를 is_active = true로 표시.

    SELECT count(*) FROM users
    WHERE email = 'dev@demo.trustedoss.dev' AND is_active;
  1. 감사 로그에 팀-추가가 memberships insert로 기록.
  1. 사용자가 배정 역할로 팀 멤버 목록에 등장.

    SELECT count(*) FROM memberships m
    JOIN users u ON u.id = m.user_id
    WHERE u.email = 'dev@demo.trustedoss.dev'
    AND m.role = 'developer';

트러블슈팅​

신규 사용자가 가입할 수 없음​

셀프 가입은 기본적으로 열려 있습니다. 사용자가 정확한 URL(/register)로 접근하는지, 이메일이 기본 형식 검증을 통과하는지, 선택한 비밀번호가 정책(12자 이상, NIST 차단 목록 외)을 만족하는지 확인하세요. 실패한 가입은 백엔드에 구조화 경고 로그를 남깁니다.

docker-compose -f docker-compose.yml logs --tail=200 backend | grep -i register

자기 자신의 역할을 승격할 수 없음​

자기 승격은 차단됩니다. 다른 super_admin에게 요청하세요. 본인이 유일한 super-admin이라면 다른 super-admin으로 로그인하세요(항상 둘 이상을 유지해야 합니다).

팀에 추가 시 "User already exists"​

이메일이 이미 포털 계정입니다(이미 다른 팀 소속일 수 있음). 기존 사용자를 팀에 추가를 사용하세요 — 같은 흐름이 이메일로 사용자를 찾아 멤버십만 부착합니다.

로드맵​

다음 기능들은 초기 문서에서 다른 곳에 기술되었으나 v0.10.0 에는 반영되지 않았습니다. 향후 마이너 릴리스를 위해 추적합니다.

  • 24시간 일회성 활성화 링크와 pending 사용자 상태가 있는 이메일 기반 초대 흐름.
  • 이메일 입력 확인 모달이 있는 사용자 소프트-삭제 동작.
  • 팀 아카이브 상태(읽기 접근은 보존하면서 숨김+비활성화).

함께 보기​