WorkCASE STUDY 04SECURE WORKSPACE · POC

Secure-Selector · 원격 업무환경 접속 제어

Windows 선택기에서 장치 확인, 대상 점유, RDP 연결과 세션 회수를 연결한 원격 업무환경을 설계하고 PoC를 구현했습니다.

주요 작업 기간
2026.08
팀
FORCS - eformsign 인프라팀
역할
기획·아키텍처 설계·주요 기능 구현 주도
구분
독립 프로젝트 · PoC

OUTCOME접속 시작부터 권한 회수까지 이어지는 원격 업무환경의 구현·PoC를 완료했습니다.

접속 경로
Windows 선택기 → RDP
접근 제어
Lease · 단기 Ticket
완료 범위
구현·PoC

배경과 역할

접속 대상을 선택하는 순간부터 종료 후 권한을 회수하는 과정까지 설계했습니다.

원격 접속의 수명 관리

사용자가 접속할 수 있는 대상과 현재 점유 상태를 확인하고, 연결 종료 후 정리까지 처리하는 흐름을 만들었습니다. 단순한 접속 목록을 넘어 권한과 세션의 수명을 관리하는 데 초점을 맞췄습니다.

선택기와 제어 계층 구현

Windows 데스크톱 선택기, Control API와 Guacamole 연결 경계를 설계하고 주요 기능을 구현했습니다. 독립적인 원격 업무환경 프로젝트로 구현·PoC를 완료했습니다.

온프레미스와 본인 PC의 사용 상태, 최근 접속 환경을 확인하는 선택기 메인 화면입니다. 공공망은 준비 중 상태로 구분합니다.

접속 아키텍처

사용자 화면, 권한 판단, 실제 원격 연결의 책임을 분리했습니다.

Windows 선택기와 Control API

선택기는 사용자에게 대상과 세션 상태를 보여주고, Control API는 장치·사용자·대상에 대한 정책과 점유를 관리합니다. 클라이언트 화면의 상태만으로 접근 권한을 판단하지 않도록 구성했습니다.

Guacamole을 통한 RDP 연결

Guacamole·guacd와 RDP 대상 사이의 연결을 제어 계층과 연계했습니다. 단기 접속 정보를 전달해 선택기에서 승인한 대상과 실제 접속 대상이 이어지도록 했습니다.

DIAGRAMSecure Selector 원격 세션 경계

핵심 구현

대상 점유와 접속 Ticket을 나눠 연결의 유효성을 관리했습니다.

Lease 기반 대상 점유

대상을 사용 중인 세션과 점유의 유효 기간을 관리했습니다. 점유 확인·갱신·해제 과정을 통해 동시에 발생하는 접속 요청과 만료 상태를 처리했습니다.

단기 Ticket으로 연결

승인된 대상에 대한 접속 정보를 단기 Ticket으로 발급하고 연결 계층에서 확인했습니다. 대상 선택과 실제 연결 사이에 별도의 유효성 확인 단계를 뒀습니다.

Lease Acquire·Idempotency

인증된 사용자의 대상 점유 요청을 Idempotency-Key와 함께 처리합니다. 이미 점유한 대상, 권한 부족, 키 충돌을 구분해 반환합니다.

main.pypython
    @app.post("/v1/targets/{target_id}/leases", response_model=LeaseResponse, status_code=status.HTTP_201_CREATED)
    async def acquire_lease(
        target_id: str,
        request: LeaseAcquireRequest,
        idempotency_header: str | None = Header(default=None, alias="Idempotency-Key"),
        principal: dict[str, Any] = Depends(current_principal),
    ) -> LeaseResponse:
        try:
            value, _ticket, _ticket_expires, reused = app.state.store.acquire(
                principal["id"], target_id, request.device_id, request.idempotency_key or idempotency_header
            )
        except KeyError as exc:
            raise _error(status.HTTP_404_NOT_FOUND, str(exc.args[0])) from exc
        except PermissionError as exc:
            raise _error(status.HTTP_409_CONFLICT, str(exc)) from exc
        except ValueError as exc:
            raise _error(status.HTTP_409_CONFLICT, str(exc)) from exc
        except RuntimeError as exc:
            raise _error(status.HTTP_409_CONFLICT, str(exc)) from exc
        except Exception as exc:
            if _is_unique_violation(exc):
                raise _error(status.HTTP_409_CONFLICT, "target_in_use") from exc
            raise
        response = _lease_model(value)
        # Reused leases remain idempotent but still return the fresh short ticket.
        if reused:
            response.ticket = value.get("ticket")
        return response

Guacamole Tunnel Ticket Binding

일회용 연결 티켓을 gateway·device·fencing 값에 결합합니다. Control API가 반환한 대상과 주체가 일치하는지 확인한 후에만 터널 연결을 허용합니다.

WorkspaceTicketBinding.javajava
    public String consume(Environment environment, TunnelRequest request,
            String authenticatedUsername, TunnelRequestType type, String identifier)
            throws GuacamoleException {

        if (type != TunnelRequestType.CONNECTION)
            throw new GuacamoleClientException("Workspace tunnels require a connection destination.");

        String ticket = required(request, TICKET_PARAMETER);
        String gatewayId = required(request, GATEWAY_PARAMETER);
        String deviceId = required(request, DEVICE_PARAMETER);
        String fencingToken = required(request, FENCING_PARAMETER);
        if (!fencingToken.matches("[1-9][0-9]{0,18}"))
            throw new GuacamoleClientException("Workspace fencing token is invalid.");
// ... endpoint 검증·Control API consume 요청 중간 생략 ...
        if (responseConnectionId == null || !identifier.equals(responseConnectionId)
                || !gatewayId.equals(responseGatewayId)
                || !dataSource.equals(responseDataSource)
                || !deviceId.equals(responseDeviceId)
                // userId is the canonical Control API display identity and
                // need not equal the external Gateway username. The explicit
                // authUsername echo is the value that must match exactly.
                || responseUserId == null
                || !authenticatedUsername.equals(responseAuthUsername)
                || responseFencingToken == null || !responseFencingToken.canConvertToLong()
                || responseFencingToken.asLong() != Long.parseLong(fencingToken))
            throw new GuacamoleClientException("Workspace ticket connection binding was rejected.");

        return responseConnectionId;
    }

세션 수명과 회수

장치 확인 → 대상 선택 → 점유 → 연결 → 종료·회수의 흐름을 구현했습니다.

접속 상태를 사용자에게 전달

선택기에서 인증과 대상 정보, 연결 진행과 세션 상태를 관리했습니다. 비동기 요청과 원격 연결의 결과를 UI 상태에 반영하도록 구성했습니다.

정상 종료와 보안 종료

정상적인 연결 종료와 권한을 폐기해야 하는 상황을 구분했습니다. 세션 정리와 점유 해제를 연결해 이후 접속에 이전 상태가 남지 않도록 했습니다.

DIAGRAM인증에서 세션 회수까지

Session Revocation Lifecycle

동일 세션의 회수 요청을 pending·in-flight·completed 상태로 합칩니다. 완료된 해제는 반복하지 않고 실패한 요청은 다시 시도할 수 있도록 수명을 관리합니다.

revocation.tstypescript
  const revoke = (session: GuacamoleAuthSession): Promise<boolean> => {
    const token = session.authToken;
    const pendingSession = pending.get(token);
    if (completed.has(token) && pendingSession && pendingSession !== session) {
      // A distinct pending object represents a newer request that was queued
      // before the completed marker became visible. Keep that boundary
      // addressable instead of treating it as the late duplicate.
      completed.delete(token);
    }
    if (completed.has(token)) {
      if (pendingSession === session) pending.delete(token);
      return Promise.resolve(true);
    }
    pending.set(token, session);

    const existingRequest = inFlight.get(token);
    if (existingRequest) return existingRequest;

    const request = revokeSession(session)
      .then(() => {
        if (pending.get(token) === session) {
          pending.delete(token);
          completed.add(token);
        }
        return true;
      })
      .catch(() => false)
      .finally(() => {
        if (inFlight.get(token) === request) inFlight.delete(token);
      });

    inFlight.set(token, request);
    return request;
  };

문제 해결

클라이언트 상태와 서버가 관리하는 권한 사이의 불일치를 다뤘습니다.

비동기 연결과 상태 정리

인증, 대상 조회와 원격 연결은 서로 다른 시점에 완료됩니다. 연결 실패와 종료 상황을 포함해 선택기의 상태 갱신과 정리 흐름을 구현했습니다.

만료와 회수의 책임

화면 종료만으로 권한 회수가 끝나지 않도록 Lease와 Ticket, 원격 세션의 종료를 구분했습니다. 각 계층이 어떤 상태를 관리하고 정리하는지 명확히 했습니다.

결과

접속 권한과 세션 수명을 연결하는 원격 업무환경 PoC를 완성했습니다.

구현·PoC 완료

Windows 선택기부터 Control API, Guacamole과 RDP 대상까지 이어지는 접속 흐름을 구현했습니다. 대상 선택과 점유, 연결 및 회수를 하나의 사용자 경험으로 구성했습니다.

접속 제어의 일관된 구조

장치 확인, 서버 측 접근 판단, 단기 접속 정보와 세션 회수의 책임을 분리했습니다. 이후 운영 환경에 맞는 정책을 확장할 수 있는 기반을 만들었습니다.