Sik.limited Logo

Tauri v2 권한 설계: capability·permission·scope를 최소 권한으로 구성하기

Tauri v2에서 WebView, Rust, 운영체제 API 사이의 신뢰 경계를 capability·permission·scope로 나누고, 파일·창·원격 콘텐츠 권한을 최소화하는 방법을 정리합니다.

Sik ·

권한 설정은 기능 목록이 아니라 신뢰 경계의 지도다

Tauri v2에서 권한을 설정하다 보면 처음에는 JSON 파일에 문자열을 추가하는 일처럼 보입니다. 파일을 열고 싶으면 fs를, 창을 조작하고 싶으면 window를, 외부 링크를 열고 싶으면 shell을 넣는 식입니다. 앱이 작을수록 “일단 default를 넣고 나중에 정리하자”는 선택도 자연스럽게 느껴집니다.

그런데 데스크톱 앱에서 이 설정은 단순한 옵션이 아닙니다. WebView 안에서 실행되는 프런트엔드 코드가 운영체제 기능에 닿을 수 있는 범위를 정의합니다. 즉 capability·permission·scope는 기능 체크리스트가 아니라 신뢰가 낮은 UI와 더 높은 권한의 시스템 사이에 그리는 지도에 가깝습니다.

Tauri의 기본 구도는 분명합니다. Rust 코어와 플러그인은 시스템 자원에 접근할 수 있고, WebView는 노출된 IPC 통로를 통해서만 그 기능을 요청합니다. 이때 프런트엔드가 XSS, 취약한 의존성, 신뢰할 수 없는 HTML, 잘못된 외부 콘텐츠로 영향을 받아도 손실을 줄이려면, 처음부터 가능한 일을 좁게 만들어야 합니다.

이 글은 Tauri v2의 세 단어를 실무 언어로 나눕니다. capability는 “어느 창이”, permission은 “무슨 명령을”, scope는 “어떤 인자 범위에서” 할 수 있는지를 설명합니다. 앞선 SvelteKit + Tauri v2 시작 가이드가 앱을 띄우는 방법을 다뤘다면, 여기서는 그 앱이 사용자 컴퓨터에서 어디까지 움직일 수 있는지를 설계합니다. Tauri·Electron의 보안 모델 차이는 SvelteKit 데스크톱 앱 비교 글, 모바일과 데스크톱의 경계는 Capacitor와 Tauri의 선택 기준에서 함께 볼 수 있습니다.


capability, permission, scope를 한 문장씩 구분하기

세 개념이 헷갈리는 이유는 실제 설정 파일에서 함께 보이기 때문입니다. 하지만 질문을 달리하면 역할이 선명해집니다.

  • capability: 이 창 또는 WebView가 이 권한 묶음을 가져도 되는가?
  • permission: 프런트엔드가 이 명령을 호출해도 되는가?
  • scope: 허용된 명령이라도 어떤 경로·URL·값에서만 가능한가?

문서 편집 앱을 예로 들어보겠습니다. 메인 편집 창은 앱 데이터 폴더의 Markdown 파일을 읽고 저장해야 합니다. 환경설정 창은 테마만 바꾸면 되고, 로그인 창은 외부 인증 화면만 보여 줍니다. 이 세 창에 같은 권한을 주는 순간 “로그인 창은 파일을 쓸 필요가 없다”는 제품 요구가 보안 설정에서는 사라집니다.

이때 capability는 메인 창만 파일 권한을 갖게 하는 층입니다. permission은 read_text_file, write_text_file 같은 명령을 허용하는 층입니다. scope는 그 명령이 $APPDATA/notes/*.md 아래에만 작동하도록 범위를 자르는 층입니다.

WebView(메인 편집 창)
  └─ capability: main-editor
       ├─ permission: fs:allow-read-text-file
       ├─ permission: fs:allow-write-text-file
       └─ scope: $APPDATA/notes/*.md

WebView(로그인 창)
  └─ capability: auth-window
       └─ permission: 필요한 창 제어만

이 구조에서 중요한 것은 capability가 사용자에게 보이는 창 제목이 아니라 창 label에 연결된다는 점입니다. title은 번역되거나 바뀔 수 있지만, label은 보안 경계를 식별하는 고정 ID여야 합니다. main, settings, auth처럼 역할이 드러나는 label을 정하고, 사용자 입력으로 label을 만들지 않는 편이 좋습니다.

가장 작은 기능 단위에서 시작해야 하는 이유

권한은 넓게 준 뒤 줄이는 방식보다, 필요한 기능을 하나씩 확인하며 추가하는 방식이 훨씬 낫습니다. 이유는 단순합니다. 넓은 권한은 정상 동작을 빨리 만들지만, 나중에는 “왜 이 권한이 여기 있지?”라는 질문에 답하기 어려워집니다. 앱이 커지면 그 파일을 만든 사람이 떠나고, 한 줄의 default가 무엇을 포함하는지 아무도 확신하지 못하게 됩니다.

처음에는 사용자 행동을 문장으로 씁니다.

  1. 메인 창에서만 새 노트를 저장한다.
  2. 환경설정 창은 로컬 파일을 다루지 않는다.
  3. 가져오기는 사용자가 명시적으로 파일을 선택했을 때만 한다.
  4. 외부 링크는 앱 내 WebView가 아니라 운영체제 브라우저에서 연다.
  5. 인증과 결제의 비밀값은 앱에 넣지 않고 서버에서 검증한다.

그다음 각 행동에 API와 경로를 붙입니다. “파일을 다룬다”는 요구는 너무 큽니다. 실제 요구가 “노트 폴더에 Markdown을 저장한다”라면, 파일 시스템 전체가 아니라 쓰기 명령 하나와 노트 폴더 한 곳만 필요합니다. 권한 설계의 품질은 설정 줄 수가 아니라, 제품 요구가 얼마나 구체적인 보안 질문으로 번역됐는지에서 나옵니다.

capability는 창마다 나누고, 중복 적용은 의식적으로 한다

Tauri v2 capability 파일은 보통 src-tauri/capabilities에 둡니다. 디렉터리 안의 capability는 기본적으로 활성화되므로, 파일을 나눴다고 자동으로 격리되는 것은 아닙니다. 같은 창 또는 WebView가 둘 이상의 capability에 포함되면 그 권한은 합쳐집니다.

이 점은 “공통 권한 파일”을 만들 때 특히 중요합니다. base.json에 모든 창을 넣고, main-editor.json에 메인 창을 다시 넣으면 메인 창은 두 파일의 권한을 모두 받습니다. 이름은 분리되어도 실제 경계는 합쳐집니다. 공통 파일에는 정말 모든 창에 필요한 읽기 전용 기능만 두고, 네이티브 접근은 역할별 capability에 두는 편이 안전합니다.

// src-tauri/capabilities/main-editor.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-editor",
  "description": "메인 편집 창에서 노트 파일을 제한적으로 다루는 권한",
  "windows": ["main"],
  "permissions": [
    "core:app:default",
    "core:event:default",
    "core:window:default",
    "dialog:allow-open",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [{ "path": "$APPDATA/notes/*.md" }]
    },
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$APPDATA/notes/*.md" }]
    }
  ]
}
// src-tauri/capabilities/settings.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "settings-window",
  "description": "환경설정 창의 최소 창 제어 권한",
  "windows": ["settings"],
  "permissions": [
    "core:window:default",
    "core:app:default"
  ]
}

예시는 읽기와 쓰기를 같은 경로로 제한합니다. 여기서 fs:default를 넣지 않은 이유는 default permission set이 실제로 무엇을 포함하는지 확인하지 않은 상태에서 권한을 넓히지 않기 위해서입니다. Tauri CLI로 플러그인을 추가하면 플러그인의 default permission이 구성에 반영될 수 있으므로, 새 플러그인을 넣은 뒤 capability 파일과 생성된 schema를 다시 보는 습관이 필요합니다.

$APPDATA 같은 경로 변수는 운영체제마다 앱 데이터 디렉터리로 해석됩니다. 사용자의 홈 폴더를 문자열로 직접 적는 것보다 이식성이 좋고, 앱 전용 데이터와 개인 파일을 구분하기에도 낫습니다. 다만 glob은 편의 기능일 뿐 제품 정책이 아닙니다. “모든 Markdown 파일”이 아니라 “노트 앱이 관리하는 Markdown 파일”만 필요하다면, 그 폴더 자체를 앱이 소유하는 구조가 더 안전합니다.


permission은 명령의 이름이 아니라 영향 범위로 읽는다

permission은 특정 IPC 명령을 프런트엔드가 부를 수 있게 합니다. 같은 플러그인 안에서도 읽기, 쓰기, 삭제, 디렉터리 열거는 영향이 다릅니다. 그래서 필요한 것만 골라야 합니다.

파일 앱의 흔한 실수는 “읽기와 저장이 되게 하려면 fs 전체를 열어야 한다”는 생각입니다. 실제로는 사용자가 열 파일을 읽는 흐름과 앱 내부 초안을 저장하는 흐름도 다릅니다. 전자는 dialog와 읽기 권한이 필요하고, 후자는 app data 하위의 쓰기 권한만 필요합니다. 삭제 기능이 없다면 삭제 permission은 넣지 않습니다. 내보내기만 필요하다면 기존 파일을 덮어쓰는 기능과 새 파일을 만드는 기능도 분리해서 검토합니다.

import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile } from '@tauri-apps/plugin-fs';

export async function selectMarkdownForImport(): Promise<string | null> {
  const selected = await open({
    directory: false,
    multiple: false,
    filters: [{ name: 'Markdown', extensions: ['md'] }]
  });

  if (typeof selected !== 'string') return null;
  return readTextFile(selected);
}

이 코드가 동작하지 않는다고 fs:default를 추가하기 전에 두 가지를 확인합니다. 첫째, 파일 선택 dialog가 돌려주는 경로가 현재 scope에 포함되는가. 둘째, 제품이 정말 사용자의 임의 위치에 있는 파일을 직접 읽어야 하는가. 단순 import라면 선택한 파일을 검증한 뒤 앱 데이터 디렉터리로 복사하고, 이후 편집은 앱 소유 폴더에서만 하는 쪽이 권한 모델과 백업 정책 모두에 유리합니다.

외부 링크도 같은 원칙입니다. shell:allow-open은 브라우저로 문서를 여는 데 편하지만, UI가 임의 문자열을 URL로 넘기면 피싱과 의도하지 않은 프로토콜 실행에 가까운 UX 문제가 생깁니다. URL을 상수 목록 또는 서버에서 검증한 HTTPS URL로 제한하고, 사용자가 입력한 값을 그대로 네이티브 호출로 흘려보내지 않는 편이 좋습니다.

scope는 허용 다음에 나오는 두 번째 질문이다

scope는 permission이 켜진 뒤에도 실제 인자를 제한합니다. 파일 시스템 플러그인에서는 경로 glob이 가장 익숙한 예입니다. allow와 deny가 함께 있을 때 deny가 우선하므로, 넓은 allow가 꼭 필요하다면 민감한 하위 경로를 deny로 명시할 수도 있습니다.

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "export-report",
  "windows": ["main"],
  "permissions": [
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$APPDATA/reports/*.csv" }],
      "deny": [{ "path": "$APPDATA/reports/private/*" }]
    }
  ]
}

하지만 이 예시를 “allow와 deny를 많이 쓰면 안전하다”는 처방으로 읽으면 안 됩니다. 가장 좋은 scope는 대개 복잡한 예외 목록이 아니라 작고 단순한 소유 디렉터리입니다. reports/private를 나중에 계속 예외로 추가하는 대신, 내보내기 대상과 민감한 데이터를 처음부터 다른 루트에 두는 편이 검토하기 쉽습니다.

scope가 모든 문제를 자동으로 해결하는 것도 아닙니다. 플러그인 또는 애플리케이션 명령이 scope를 해석하고 집행해야 합니다. 특히 직접 만든 Rust 명령은 “프런트엔드에서 온 문자열을 받았으니 scope가 알아서 막겠지”라고 가정하면 안 됩니다. 파일명, URL, 레코드 ID 같은 입력을 Rust 쪽에서도 도메인 규칙으로 다시 검증해야 합니다.

직접 만든 Rust 명령은 별도의 제품 API로 취급한다

Tauri 명령은 프런트엔드와 Rust 사이의 IPC API입니다. UI에서만 부른다고 해서 내부 함수처럼 다루면 곤란합니다. 인자 타입, 최대 길이, 파일명 규칙, 권한 확인, 오류 메시지를 공개 API처럼 설계해야 합니다.

노트 저장을 예로 들면, 프런트가 임의의 경로를 보내서 Rust가 그대로 쓰게 만드는 방식보다, 프런트는 노트 이름과 내용만 보내고 Rust가 앱 데이터 폴더 안에서 목적 경로를 만드는 방식이 낫습니다.

use std::fs;
use tauri::Manager;

#[tauri::command]
fn save_note(
    app: tauri::AppHandle,
    note_id: String,
    markdown: String,
) -> Result<(), String> {
    if note_id.is_empty()
        || note_id.len() > 80
        || !note_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err("유효하지 않은 노트 식별자입니다.".into());
    }

    if markdown.len() > 2_000_000 {
        return Err("노트 크기가 허용 범위를 넘었습니다.".into());
    }

    let notes_dir = app
        .path()
        .app_data_dir()
        .map_err(|error| error.to_string())?
        .join("notes");

    fs::create_dir_all(&notes_dir).map_err(|error| error.to_string())?;
    fs::write(notes_dir.join(format!("{note_id}.md")), markdown)
        .map_err(|error| error.to_string())
}

이 명령은 경로를 인자로 받지 않으므로 ../ 같은 경로 탐색 문자열을 파일 경로로 해석할 여지를 줄입니다. 물론 이 예시는 하나의 패턴일 뿐입니다. 대용량 파일, 바이너리 첨부, 사용자가 원본 위치에 저장해야 하는 편집기라면 다른 요구가 생깁니다. 그때도 핵심은 같습니다. 프런트에서 받은 편의상 인자를 운영체제 API에 곧바로 넘기지 말고, Rust 쪽에서 제품 규칙으로 한 번 좁힙니다.

애플리케이션 자체 명령을 capability로 제한하고 싶다면 빌드 단계에서 명령 manifest를 명시하는 방법도 있습니다. 등록한 앱 명령은 기본적으로 모든 창과 WebView가 쓸 수 있으므로, 민감한 명령에는 이를 검토할 가치가 큽니다.

// src-tauri/build.rs
fn main() {
    tauri_build::try_build(
        tauri_build::Attributes::new()
            .app_manifest(
                tauri_build::AppManifest::new().commands(&["save_note"])
            )
    )
    .unwrap();
}

그리고 애플리케이션용 permission을 src-tauri/permissions에 선언합니다.

# src-tauri/permissions/notes.toml
[[permission]]
identifier = "save-note"
description = "앱 데이터 디렉터리에 검증된 노트를 저장한다."
commands.allow = ["save_note"]
// src-tauri/capabilities/main-editor.json의 permissions 일부
[
  "save-note"
]

여기서 permission 이름과 command 이름은 같은 문자열일 필요가 없습니다. permission은 사람이 검토할 수 있는 권한 정책의 이름이고, command는 실제 IPC 함수입니다. “어떤 창이 어떤 제품 행동을 할 수 있는가”가 읽히는 이름을 붙이는 편이 좋습니다.


원격 콘텐츠에는 로컬 권한을 기본값으로 주지 않는다

번들 안에 들어간 앱 코드와 인터넷에서 내려온 콘텐츠는 신뢰 수준이 다릅니다. Tauri의 원격 API 접근은 기본적으로 열려 있지 않으며, 원격 URL에 네이티브 명령을 노출하려면 capability에 별도 설정을 해야 합니다. 이것은 편의를 위해 무심코 켜기보다, 원격 페이지가 정말 해당 네이티브 기능을 호출해야 하는지부터 다시 묻는 기능입니다.

웹 기반 도움말, OAuth 화면, 원격 대시보드를 앱 WebView 안에 띄울 수는 있습니다. 하지만 그 화면에 파일 접근, 클립보드 쓰기, 로컬 데이터베이스, 창 생성 같은 권한이 필요하지 않은 경우가 대부분입니다. 원격 콘텐츠가 필요한 앱이라면 다음 중 하나를 먼저 선택합니다.

  • 원격 화면은 시스템 브라우저에서 열고, 앱 권한과 분리한다.
  • 원격 화면을 별도 label의 WebView에 띄우고, 로컬 capability를 붙이지 않는다.
  • 정말 필요한 한두 개의 명령만 정확한 HTTPS URL 패턴에 노출하고, iframe·리디렉션·플랫폼별 차이를 별도 테스트한다.

특히 Linux와 Android에서는 iframe 요청과 창 자체 요청을 구분하지 못할 수 있다는 제약도 문서에 명시되어 있습니다. “도메인을 allowlist 했으니 안전하다”는 결론으로 끝내면 안 되는 이유입니다. 원격 URL을 포함하는 기능은 기능 설계, CSP, 인증 redirect, WebView 동작, 권한 설계를 함께 검토해야 합니다.

모바일까지 염두에 둔다면 platform 분리부터 한다

Tauri v2는 데스크톱뿐 아니라 모바일도 겨냥합니다. 하지만 같은 capability를 모든 플랫폼에 그대로 적용하는 것이 곧 코드 재사용은 아닙니다. 글로벌 단축키처럼 데스크톱에만 있는 플러그인, NFC·생체 인증처럼 모바일에 가까운 기능은 capability도 플랫폼별로 나눠야 합니다.

// src-tauri/capabilities/desktop-shortcuts.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "desktop-shortcuts",
  "windows": ["main"],
  "platforms": ["linux", "macOS", "windows"],
  "permissions": ["global-shortcut:allow-register"]
}

이 분리는 단지 빌드 오류를 피하는 방법이 아닙니다. 같은 제품 기능이라도 데스크톱에서는 키보드 단축키, 모바일에서는 생체 인증 또는 시스템 공유 시트처럼 전혀 다른 운영체제 행동이 될 수 있습니다. Capacitor를 포함해 모바일 WebView를 운영하는 선택지와 비교할 때도, 프런트엔드 코드 공유율보다 권한과 사용자 기대를 얼마나 다르게 설계해야 하는지가 더 중요한 질문입니다.

권한 오류를 해결하는 순서가 권한 품질을 결정한다

실전에서 가장 자주 만나는 오류는 단순합니다. 호출은 했는데 허용되지 않았다는 rejection입니다. 이때 아래 순서로 보면 원인을 좁히면서 권한을 불필요하게 넓히지 않을 수 있습니다.

  1. 호출한 API가 실제로 어떤 permission identifier를 요구하는지 생성 schema와 공식 문서에서 확인합니다.
  2. 호출하는 창의 label이 해당 capability의 windows에 정확히 들어 있는지 확인합니다.
  3. 같은 창에 여러 capability가 적용돼 예상 밖으로 권한이 합쳐지지 않았는지 봅니다.
  4. API 자체 permission과 인자 scope를 분리해 봅니다. 명령이 막힌 것인지, 경로·URL이 scope 밖인 것인지 확인합니다.
  5. 파일 경로, URL, command 인자가 제품 규칙을 통과하는지 Rust와 프런트 양쪽에서 점검합니다.
  6. 개발 모드뿐 아니라 실제 번들에서도 확인합니다. 특히 원격 URL과 플랫폼별 WebView는 패키지 결과에서 봐야 합니다.

이 과정은 답답해 보이지만, 권한 오류를 만날 때마다 앱의 요구를 더 작은 문장으로 만드는 과정이기도 합니다. “파일 시스템을 열어야 한다”가 아니라 “메인 창에서만 앱 데이터의 초안을 저장해야 한다”로 질문이 바뀌면, 설정도 테스트도 훨씬 명확해집니다.

배포 전 최소 권한 점검표

릴리스 전에 capability 파일을 코드 리뷰 대상처럼 읽어보는 시간이 필요합니다. 다음 질문에 “예”라고 답할 수 있으면 좋은 출발점입니다.

  • 모든 capability의 windows가 사용자에게 보이는 역할이 아니라 고정 label과 연결되어 있는가?
  • 한 창에 여러 capability를 붙인 이유와 합쳐지는 permission을 설명할 수 있는가?
  • default permission set을 추가한 줄마다 포함 범위를 확인했는가?
  • 읽기, 쓰기, 삭제, 실행, 외부 URL 열기를 서로 다른 영향으로 취급했는가?
  • file scope가 홈 전체가 아니라 앱 소유 디렉터리 또는 꼭 필요한 사용자 선택 경로로 제한되어 있는가?
  • scope의 deny 예외가 계속 늘어나고 있지는 않은가? 그렇다면 디렉터리 구조를 더 작게 바꿀 수 있는가?
  • Rust command가 경로·URL·식별자·크기를 검증하고, UI 입력을 시스템 호출에 그대로 넘기지 않는가?
  • 원격 페이지나 iframe에 로컬 native API를 열어두지 않았는가?
  • 데스크톱 전용 permission과 모바일 전용 permission을 platforms로 분리했는가?
  • 개발 모드와 릴리스 번들에서 모두 창별 권한을 테스트했는가?

보안은 권한 파일만으로 완성되지 않는다

capability·permission·scope는 강력하지만, 앱 전체를 마법처럼 안전하게 만들지는 않습니다. Rust 코어가 잘못된 입력을 신뢰하거나, scope가 지나치게 넓거나, WebView 자체의 취약점과 공급망 문제가 있으면 설정만으로 해결할 수 없습니다. 그래서 이 모델을 “보안 기능 하나”가 아니라, 사고가 났을 때 영향 범위를 줄이는 기본 구조로 보는 편이 정확합니다.

좋은 Tauri 앱은 권한이 많은 앱이 아니라, 사용자 기능에 비해 권한의 이유가 선명한 앱입니다. 메인 편집 창은 노트를 저장할 수 있지만 로그인 창은 못 하고, 파일을 읽을 수 있어도 앱이 소유한 폴더에서만 가능하고, WebView에서 올라온 요청은 Rust가 다시 검증합니다. 이 정도만 지켜도 UI와 운영체제 사이의 경계는 훨씬 다루기 쉬워집니다.

Tauri v2의 최소 권한 설계는 개발 속도를 늦추는 절차가 아닙니다. 기능을 추가할 때마다 “어느 창이, 어떤 명령을, 어떤 데이터에 대해 실행하는가”를 명확히 하는 제품 설계입니다. 그 질문에 답할 수 있는 설정은 시간이 지나도 유지보수할 수 있고, 답할 수 없는 설정은 언젠가 default라는 이름의 부채가 됩니다.

출처

최신 글