테넌트 컨텍스트 문제 해결
빈 결과를 주는 쿼리, 예상하지 못한 거부, 요청과 다르게 동작하는 잡을 진단합니다.
테넌트 컨텍스트 문제
테넌트 컨텍스트는 권한 평가보다 먼저 확립됩니다. 무언가 빈 결과를 주거나 예상 밖으로 거부하면, 코드를 바꾸기 전에 컨텍스트와 Grant를 확인하세요.
격리 단정이 실패한다고 테넌트 스코프를 끄지 마세요. 단정이 보통 옳고 설정이 불완전합니다. 스코프를 약화하면 잡힌 버그가 배포된 버그로 바뀝니다.
증상 색인
| 증상 | 절 |
|---|---|
| 쿼리가 0건을 돌려줌 | 쿼리가 빈 결과를 준다 |
"권한이 있는" 행위자에게 예상 밖 403 | 권한이 예상 밖으로 거부한다 |
| 관리자가 자기 레코드는 보는데 팀 레코드를 못 봄 | 모집단이 예상보다 좁다 |
| Grant가 있는데 아무것도 닿지 않음 | Grant 범위가 권한과 맞지 않는다 |
| 큐 잡이 요청과 다르게 동작 | 잡이 컨텍스트를 잃었다 |
| 요청이 테넌트가 아니라 central 도메인에 닿음 | tenancy initialize가 호스트를 바꾸지 않는다 |
쿼리가 빈 결과를 준다
The tenant-scoped query returns zero records.
원인. 가능성 순서로 셋 중 하나입니다. 테넌트를 고르지 않았거나, 행위자에게 Grant가 없어 가시성 스코프가 전부를 배제하거나, 행이 다른 테넌트에서 생성됐습니다.
해결. 순서대로.
- 테넌트 모델에 접근하기 전에 테넌트를 골랐는지 확인하세요. 테넌트 컨텍스트 밖에서 건드린 모델은 잘못된 연결을 읽습니다.
- 동작이 요구하는 Legal Entity 멤버십 과 Access Grant를 만드세요. Grant 없는 가시성 스코프는 올바르게 빈 결과를 돌려줍니다.
- 픽스처 행이 같은 테넌트 안에서 생성됐는지 확인하세요.
확인. 같은 쿼리가 Grant를 받은 행위자에게는 행을 돌려주고 받지 않은 행위자에게는 여전히 빈 결과를 돌려줍니다.
예방. 빈 결과는 "Grant 없음"에 대한 올바른 답입니다. 스코프 버그가 아니라 설정 공백으로 취급하세요.
권한이 예상 밖으로 거부한다
403 Forbidden for an actor who has the permission key.
원인. 권한만으로는 접근이 아닙니다. Policy가 권한 과 레코드가 범위 안인지를 확인하므로, 다른 Legal Entity의 레코드는 권한이 있어도 거부됩니다.
해결. 사슬 전체를 만드세요.
| 요구 사항 | 이유 |
|---|---|
| 테넌트 컨텍스트 | 권한 평가보다 먼저 확립됨 |
| 권한을 담은 Role | 정의만으로는 아무것도 부여하지 않음 |
| 권한이 받아들이는 범위의 Access Grant | tenant, legal_entity, operating_unit — 그리고 그 차원이 Route의 target에 있어야 함 |
| Legal Entity 멤버십 | Route가 URL이 아니라 행위자에게서 Legal Entity를 해석하는 경우에 필요 |
| 그 범위 안의 레코드 | recordMatches()가 확인 |
확인. 허용 경우가 성공하고 거부 경우는 여전히 403입니다.
예방. 모든 픽스처에 권한 없는 행위자를 두세요. 관리자만 사용하는 스위트는 빠진 검사를 감지할 수 없습니다.
모집단이 예상보다 좁다
A manager sees their own record but not their team's records.
원인. Grant의 대상 집단이 업무에 필요한 범위보다 좁을 수 있습니다. self는 본인 레코드에만 적용됩니다. 직속 보고자를 관리하려면 지원되는 대상 집단과 관계 근거가 필요합니다. 쿼리를 통과시키려고 내부 wildcard인 all로 바꾸지 마세요.
해결. Grant의 모집단을 연산이 필요한 것과 비교하세요.
| 모집단 | 닿는 범위 |
|---|---|
all | 보호·복구 권한의 내부 wildcard; SubjectPopulation enum case가 아님 |
self | 행위자 자신의 레코드 |
direct_reports | 행위자에게 직접 보고하는 사람들의 레코드 |
legal_entity | Grant의 Legal Entity 안 레코드 |
operating_unit | Grant의 Operating Unit 안 레코드 |
확인. 행위자가 의도한 집합에 정확히 닿습니다. 더 넓지 않습니다.
예방. reporting_tree와 assigned_records는 doctrine에 나오지만 SubjectPopulation에는 없습니다. enum이 실제로 동작하는 것이므로 나머지를 전제로 설계하지 마세요.
Grant 범위가 권한과 맞지 않는다
An Access Grant exists, but the protected action still reaches no records.
원인. AssignmentScope가 범위에 서열을 매기고, Grant는 권한의 선언 범위보다 같거나 거칠어야 합니다.
public function supportsGrantAt(self $grantScope): bool
{
return $grantScope->rank() <= $this->rank(); // Tenant 0, LegalEntity 1, OperatingUnit 2
}Tenant로 선언된 권한은 오직 Tenant Grant만 받습니다. Legal Entity Grant는 더 좁아서 만족시키지 못합니다. 대부분의 직관과 반대입니다.
해결. 권한이 의도한 할당 범위를 먼저 확인하고 접근 관리자가 올바른 역할과 범위를 부여하게 합니다. 업무 권한 계약 자체가 잘못된 경우에만 권한 선언을 변경하세요. 보호된 API 추가하기를 참고합니다.
확인. 의도한 Grant로 동작이 성공하고, 자격이 없어야 하는 Grant로는 여전히 실패합니다.
예방. 하위 트리가 필요하면 include_descendants를 지원하는 범위 타입은 core.operating_unit뿐입니다. Legal Entity 관계는 지원하지 않습니다.
잡이 컨텍스트를 잃었다
A queued job behaves differently from the equivalent HTTP request.
원인. HTTP 요청에서는 Tenant와 로그인 사용자가 미들웨어를 통해 확립되지만, 비동기 실행에는 그 요청 상태가 이어지지 않습니다. 모든 비동기 작업은 소유 Tenant 안에서 실행돼야 합니다. 사용자를 대신해 보호된 동작을 수행하는 작업만 해당 사용자를 다시 확인하고 실행 시점 권한을 평가해야 합니다.
해결. Queue·scheduler 인프라는 신뢰된 Tenant key로
TenantRunner::runFor($tenantKey, $callback)을 호출할 수 있습니다. Tenant가 더는
존재하지 않으면 false를 반환합니다. 이벤트 소비자는 플랫폼이 이미 소유 Tenant
안에서 호출하므로 envelope의 Tenant와 현재 Tenant가 같은지만 확인합니다.
// tenantId 는 non-nullable string 이다. create() 가 테넌트 없으면 '' 로 캐스팅한다.
if (trim($envelope->tenantId) === '' || $envelope->tenantId !== (string) tenant()?->getTenantKey()) {
throw new RuntimeException('Refusing to consume an envelope outside the current tenant.');
}사용자 권한과 무관한 system projection은 AppEventListeners::listen()으로
등록합니다. 사용자를 대신하는 보호된 동작은
ActorDelegatedAppEventListeners::listenDelegated()과 EventActorAuthorizer를
사용합니다. 플랫폼이 실행 직전에 envelope의 사용자를 조회·검증하고 현재 권한을
재평가하므로, handler가 actorId를 인증 상태로 직접 설치하지 않습니다. actorId는
위임의 근거이지 로그인 session이 아닙니다.
확인. system projection은 사용자 없이 실행되고, delegated listener는 사용자가 없거나 권한을 잃었을 때 실행되지 않습니다. Tenant가 없거나 envelope과 다르면 두 종류 모두 실행되지 않습니다.
예방. 복원 실패 후 기본 테넌트로 폴백하는 것은 doctrine 위반(TB-3)입니다. 컨텍스트가 없거나 모호하면 안전하게 실패해야 합니다.
tenancy initialize가 호스트를 바꾸지 않는다
The request resolves on the central host after tenancy initialization.
원인. tenancy()->initialize()는 앱 수준 컨텍스트만 바꿉니다. HTTP 요청은 여전히 central 도메인을 향합니다.
해결. 요청이 테넌트 도메인으로 해석돼야 할 때 요청에 HTTP_HOST를 명시적으로 설정하세요.
확인. 요청이 central이 아니라 테넌트 Route로 해석됩니다.
예방. 앱 컨텍스트와 요청 호스트는 별개 사실입니다.
관련 문서
- 테넌트와 컨텍스트 — 컨텍스트가 권한보다 먼저인 이유
- 권한과 역할 — 모든 거부 뒤의 4부 모델
- 테스트 실패 해결 — 원인이 컨텍스트가 아니라 테스트 인프라일 때
- 이벤트와 비동기 작업 처리하기 — 소비자에서 컨텍스트 복원