chamelion-vault 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chamelion_vault-0.1.0/.gitignore +19 -0
- chamelion_vault-0.1.0/PKG-INFO +155 -0
- chamelion_vault-0.1.0/README.md +148 -0
- chamelion_vault-0.1.0/pyproject.toml +27 -0
- chamelion_vault-0.1.0/src/chamelion_vault/__init__.py +42 -0
- chamelion_vault-0.1.0/src/chamelion_vault/_client.py +167 -0
- chamelion_vault-0.1.0/src/chamelion_vault/_errors.py +88 -0
- chamelion_vault-0.1.0/src/chamelion_vault/_secret.py +74 -0
- chamelion_vault-0.1.0/src/chamelion_vault/py.typed +0 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.env.*
|
|
3
|
+
!.env.example
|
|
4
|
+
|
|
5
|
+
.venv/
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.pyc
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
|
|
12
|
+
data/
|
|
13
|
+
*.log
|
|
14
|
+
|
|
15
|
+
.DS_Store
|
|
16
|
+
|
|
17
|
+
# 클라이언트 배포물 — 만들 때마다 생긴다. 저장소에 두지 않는다.
|
|
18
|
+
clients/python/dist/
|
|
19
|
+
clients/python/uv.lock
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chamelion-vault
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for a self-hosted chamelion vault gateway
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
<!-- ⚠️ 이 파일은 PyPI 설명란에 그대로 실린다 — 전 세계에 공개된다.
|
|
9
|
+
볼트 주소·DDNS·포트·조직 이름·실제 시크릿 경로를 적지 않는다.
|
|
10
|
+
예시는 언제나 명백한 가짜(`myproject/dev/EXAMPLE_KEY`)로 쓴다.
|
|
11
|
+
저장소의 `tests/test_client_package.py` 가 이 규칙을 검사한다. -->
|
|
12
|
+
|
|
13
|
+
# chamelion-vault
|
|
14
|
+
|
|
15
|
+
자체 호스팅 chamelion vault 게이트웨이에서 **시크릿 값을 받아 오는 클라이언트**입니다.
|
|
16
|
+
|
|
17
|
+
- **의존성이 없습니다.** 표준 라이브러리만 씁니다.
|
|
18
|
+
- **값을 디스크에 쓰지 않고 캐시하지 않습니다.** 받은 값은 프로세스 메모리에만 있습니다.
|
|
19
|
+
- **로깅을 설정하지 않습니다.** 이 패키지는 여러분의 애플리케이션 안에서 돕니다.
|
|
20
|
+
|
|
21
|
+
> 이 패키지는 **볼트를 운영하는 조직의 구성원**을 위한 것입니다. 볼트 주소·머신 API 키·
|
|
22
|
+
> 터널 접속 정보는 여기 없고 사내 문서에 있습니다. 그것을 모르면 설치까지는 되지만
|
|
23
|
+
> 값을 받는 단계에서 막히는 것이 정상입니다.
|
|
24
|
+
|
|
25
|
+
## 설치
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install chamelion-vault
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 쓰는 법
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from chamelion_vault import get_secret
|
|
35
|
+
|
|
36
|
+
secret = get_secret("myproject", "dev", "EXAMPLE_KEY")
|
|
37
|
+
api_key = secret["value"].get_secret_value()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
값이 하나뿐인 시크릿은 `["value"]` 에 있습니다. 한 몸인 크리덴셜을 한 경로에 여러
|
|
41
|
+
필드로 둔 것을 **번들**이라 부르고, 그때는 필드가 여럿입니다.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
secret = get_secret("myproject", "dev", "EXAMPLE_BUNDLE")
|
|
45
|
+
sorted(secret) # ['access_key', 'secret_key']
|
|
46
|
+
secret["access_key"].get_secret_value()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
프로젝트·환경이 같고 이름만 여럿이면 한 번에 받을 수 있습니다. **요청은 건별로 나가고**,
|
|
50
|
+
하나라도 실패하면 전부 실패로 끝납니다 — 반쪽짜리 설정으로 애플리케이션이 뜨는 것보다
|
|
51
|
+
뜨지 않는 편이 낫기 때문입니다.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from chamelion_vault import get_secrets
|
|
55
|
+
|
|
56
|
+
secrets = get_secrets("myproject", "dev", ["EXAMPLE_KEY", "EXAMPLE_DB_URL"])
|
|
57
|
+
secrets["EXAMPLE_KEY"]["value"].get_secret_value()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 값이 상자에 담겨 옵니다
|
|
61
|
+
|
|
62
|
+
`get_secret_value()` 로 열기 전까지 값은 어디에도 그대로 나타나지 않습니다.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
print(secret) # Secret(myproject/dev/EXAMPLE_KEY, fields=['value'])
|
|
66
|
+
print(secret["value"]) # <REDACTED>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
무심코 남긴 로그 한 줄에 진짜 키가 찍히는 것을 막습니다. **연 뒤에는 막아 주지 않으니**
|
|
70
|
+
꺼낸 값은 바로 쓰고 변수에 오래 들고 있지 마세요.
|
|
71
|
+
|
|
72
|
+
## 설정 — 환경변수 둘
|
|
73
|
+
|
|
74
|
+
| 환경변수 | 뜻 | 기본값 |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `CHAMELION_VAULT_KEY` | 머신 API 키 | 없음 (필수) |
|
|
77
|
+
| `CHAMELION_VAULT_URL` | 게이트웨이 주소 | `http://127.0.0.1:8081` |
|
|
78
|
+
|
|
79
|
+
**설정 파일은 지원하지 않습니다.** 파일을 읽게 만들면 거기에 키를 적게 되고, 그 파일은
|
|
80
|
+
언젠가 저장소에 들어갑니다. 키를 인자로 받는 함수도 두지 않았습니다 — 같은 이유입니다.
|
|
81
|
+
|
|
82
|
+
머신 API 키는 Slack 에서 받습니다.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
/vault machine
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
관리자 승인 뒤 **한 번만** 표시됩니다. 잃어버리면 다시 요청하세요.
|
|
89
|
+
|
|
90
|
+
## 터널
|
|
91
|
+
|
|
92
|
+
게이트웨이는 볼트 호스트의 **루프백에만** 열려 있습니다. 밖에서 값을 받으려면 그
|
|
93
|
+
루프백까지 가는 SSH 터널이 필요하고, **이 패키지는 터널을 대신 띄우지 않습니다.**
|
|
94
|
+
프로세스 수명·키 경로·OS 차이가 전부 따라 들어와, 실패했을 때 터널 문제인지 조회
|
|
95
|
+
문제인지 구분이 안 되기 때문입니다.
|
|
96
|
+
|
|
97
|
+
`~/.ssh/config` 에 한 번 적어 두면 그 뒤로는 한 줄입니다.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Host vault-tunnel
|
|
101
|
+
HostName <볼트 호스트>
|
|
102
|
+
Port <SSH 포트>
|
|
103
|
+
User <계정>
|
|
104
|
+
IdentityFile ~/.ssh/<터널 키>
|
|
105
|
+
IdentitiesOnly yes
|
|
106
|
+
LocalForward 8081 127.0.0.1:8081
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
ssh -N vault-tunnel
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> ⚠️ **`IdentitiesOnly yes` 를 빠뜨리지 마세요.** 이것이 없으면 기본 키(`~/.ssh/id_*`)가
|
|
114
|
+
> 먼저 인증을 시도해, 터널 키에 걸어 둔 제약이 통째로 비껴갑니다. 실패 원인을 찾기
|
|
115
|
+
> 가장 어려운 자리입니다.
|
|
116
|
+
|
|
117
|
+
`<볼트 호스트>`·`<SSH 포트>`·`<계정>`·`<터널 키>` 는 사내 터널 문서에 있습니다.
|
|
118
|
+
|
|
119
|
+
## 값을 받지 못했을 때
|
|
120
|
+
|
|
121
|
+
볼트는 **실패 원인을 알려주지 않습니다.** 키가 틀렸는지·권한이 없는지·그런 시크릿이
|
|
122
|
+
없는지를 응답으로 가르면 그것이 곧 남의 볼트를 훑는 도구가 되기 때문입니다. 그래서
|
|
123
|
+
이 패키지도 추측하지 않고, 대신 확실히 아는 것만 말합니다.
|
|
124
|
+
|
|
125
|
+
| 예외 | 뜻 | 할 일 |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `MissingApiKeyError` | 환경변수가 비었다 | `/vault machine` 으로 키를 받는다 |
|
|
128
|
+
| `VaultUnreachableError` | 연결 자체가 안 됐다 | 터널이 떠 있는지 본다 |
|
|
129
|
+
| `SecretUnavailableError` | 볼트가 값을 주지 않았다 | 아래 셋을 순서대로 확인한다 |
|
|
130
|
+
| `UnexpectedResponseError` | 계약에 없는 응답이 왔다 | 주소가 정말 게이트웨이인지 확인한다 |
|
|
131
|
+
|
|
132
|
+
`SecretUnavailableError` 의 원인은 셋입니다.
|
|
133
|
+
|
|
134
|
+
1. **접근 그룹** — 그 시크릿에 접근 그룹이 지정돼 있지 않으면 머신은 값을 받지
|
|
135
|
+
못합니다. **가장 흔한 원인**이니 소유자에게 먼저 확인하세요.
|
|
136
|
+
2. **머신 키** — 아직 승인 전이거나 폐기됐을 수 있습니다.
|
|
137
|
+
3. **경로** — 프로젝트·환경·이름 중 하나가 틀렸을 수 있습니다.
|
|
138
|
+
|
|
139
|
+
네 예외는 모두 `VaultError` 를 상속하므로 한 번에 잡을 수 있습니다.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from chamelion_vault import VaultError
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
secret = get_secret("myproject", "dev", "EXAMPLE_KEY")
|
|
146
|
+
except VaultError as error:
|
|
147
|
+
print(error) # 무엇을 해야 하는지가 메시지에 들어 있습니다
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## 받을 수 없는 것
|
|
151
|
+
|
|
152
|
+
- **개인 등급 시크릿.** 소유자 본인만 Slack 으로 받습니다. 머신에게는 열리지 않고,
|
|
153
|
+
관리자도 값을 볼 수 없습니다. 코드에서 써야 하는 값이라면 개인 등급으로 두면 안 됩니다.
|
|
154
|
+
- **프로젝트에 딸린 시크릿 목록.** 게이트웨이는 이름을 알고 있는 시크릿 한 건을 줄 뿐,
|
|
155
|
+
"이 프로젝트에 무엇이 있는지" 는 알려주지 않습니다. 목록은 Slack 에서 봅니다.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<!-- ⚠️ 이 파일은 PyPI 설명란에 그대로 실린다 — 전 세계에 공개된다.
|
|
2
|
+
볼트 주소·DDNS·포트·조직 이름·실제 시크릿 경로를 적지 않는다.
|
|
3
|
+
예시는 언제나 명백한 가짜(`myproject/dev/EXAMPLE_KEY`)로 쓴다.
|
|
4
|
+
저장소의 `tests/test_client_package.py` 가 이 규칙을 검사한다. -->
|
|
5
|
+
|
|
6
|
+
# chamelion-vault
|
|
7
|
+
|
|
8
|
+
자체 호스팅 chamelion vault 게이트웨이에서 **시크릿 값을 받아 오는 클라이언트**입니다.
|
|
9
|
+
|
|
10
|
+
- **의존성이 없습니다.** 표준 라이브러리만 씁니다.
|
|
11
|
+
- **값을 디스크에 쓰지 않고 캐시하지 않습니다.** 받은 값은 프로세스 메모리에만 있습니다.
|
|
12
|
+
- **로깅을 설정하지 않습니다.** 이 패키지는 여러분의 애플리케이션 안에서 돕니다.
|
|
13
|
+
|
|
14
|
+
> 이 패키지는 **볼트를 운영하는 조직의 구성원**을 위한 것입니다. 볼트 주소·머신 API 키·
|
|
15
|
+
> 터널 접속 정보는 여기 없고 사내 문서에 있습니다. 그것을 모르면 설치까지는 되지만
|
|
16
|
+
> 값을 받는 단계에서 막히는 것이 정상입니다.
|
|
17
|
+
|
|
18
|
+
## 설치
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install chamelion-vault
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 쓰는 법
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from chamelion_vault import get_secret
|
|
28
|
+
|
|
29
|
+
secret = get_secret("myproject", "dev", "EXAMPLE_KEY")
|
|
30
|
+
api_key = secret["value"].get_secret_value()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
값이 하나뿐인 시크릿은 `["value"]` 에 있습니다. 한 몸인 크리덴셜을 한 경로에 여러
|
|
34
|
+
필드로 둔 것을 **번들**이라 부르고, 그때는 필드가 여럿입니다.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
secret = get_secret("myproject", "dev", "EXAMPLE_BUNDLE")
|
|
38
|
+
sorted(secret) # ['access_key', 'secret_key']
|
|
39
|
+
secret["access_key"].get_secret_value()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
프로젝트·환경이 같고 이름만 여럿이면 한 번에 받을 수 있습니다. **요청은 건별로 나가고**,
|
|
43
|
+
하나라도 실패하면 전부 실패로 끝납니다 — 반쪽짜리 설정으로 애플리케이션이 뜨는 것보다
|
|
44
|
+
뜨지 않는 편이 낫기 때문입니다.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from chamelion_vault import get_secrets
|
|
48
|
+
|
|
49
|
+
secrets = get_secrets("myproject", "dev", ["EXAMPLE_KEY", "EXAMPLE_DB_URL"])
|
|
50
|
+
secrets["EXAMPLE_KEY"]["value"].get_secret_value()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 값이 상자에 담겨 옵니다
|
|
54
|
+
|
|
55
|
+
`get_secret_value()` 로 열기 전까지 값은 어디에도 그대로 나타나지 않습니다.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
print(secret) # Secret(myproject/dev/EXAMPLE_KEY, fields=['value'])
|
|
59
|
+
print(secret["value"]) # <REDACTED>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
무심코 남긴 로그 한 줄에 진짜 키가 찍히는 것을 막습니다. **연 뒤에는 막아 주지 않으니**
|
|
63
|
+
꺼낸 값은 바로 쓰고 변수에 오래 들고 있지 마세요.
|
|
64
|
+
|
|
65
|
+
## 설정 — 환경변수 둘
|
|
66
|
+
|
|
67
|
+
| 환경변수 | 뜻 | 기본값 |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `CHAMELION_VAULT_KEY` | 머신 API 키 | 없음 (필수) |
|
|
70
|
+
| `CHAMELION_VAULT_URL` | 게이트웨이 주소 | `http://127.0.0.1:8081` |
|
|
71
|
+
|
|
72
|
+
**설정 파일은 지원하지 않습니다.** 파일을 읽게 만들면 거기에 키를 적게 되고, 그 파일은
|
|
73
|
+
언젠가 저장소에 들어갑니다. 키를 인자로 받는 함수도 두지 않았습니다 — 같은 이유입니다.
|
|
74
|
+
|
|
75
|
+
머신 API 키는 Slack 에서 받습니다.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
/vault machine
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
관리자 승인 뒤 **한 번만** 표시됩니다. 잃어버리면 다시 요청하세요.
|
|
82
|
+
|
|
83
|
+
## 터널
|
|
84
|
+
|
|
85
|
+
게이트웨이는 볼트 호스트의 **루프백에만** 열려 있습니다. 밖에서 값을 받으려면 그
|
|
86
|
+
루프백까지 가는 SSH 터널이 필요하고, **이 패키지는 터널을 대신 띄우지 않습니다.**
|
|
87
|
+
프로세스 수명·키 경로·OS 차이가 전부 따라 들어와, 실패했을 때 터널 문제인지 조회
|
|
88
|
+
문제인지 구분이 안 되기 때문입니다.
|
|
89
|
+
|
|
90
|
+
`~/.ssh/config` 에 한 번 적어 두면 그 뒤로는 한 줄입니다.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Host vault-tunnel
|
|
94
|
+
HostName <볼트 호스트>
|
|
95
|
+
Port <SSH 포트>
|
|
96
|
+
User <계정>
|
|
97
|
+
IdentityFile ~/.ssh/<터널 키>
|
|
98
|
+
IdentitiesOnly yes
|
|
99
|
+
LocalForward 8081 127.0.0.1:8081
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
ssh -N vault-tunnel
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
> ⚠️ **`IdentitiesOnly yes` 를 빠뜨리지 마세요.** 이것이 없으면 기본 키(`~/.ssh/id_*`)가
|
|
107
|
+
> 먼저 인증을 시도해, 터널 키에 걸어 둔 제약이 통째로 비껴갑니다. 실패 원인을 찾기
|
|
108
|
+
> 가장 어려운 자리입니다.
|
|
109
|
+
|
|
110
|
+
`<볼트 호스트>`·`<SSH 포트>`·`<계정>`·`<터널 키>` 는 사내 터널 문서에 있습니다.
|
|
111
|
+
|
|
112
|
+
## 값을 받지 못했을 때
|
|
113
|
+
|
|
114
|
+
볼트는 **실패 원인을 알려주지 않습니다.** 키가 틀렸는지·권한이 없는지·그런 시크릿이
|
|
115
|
+
없는지를 응답으로 가르면 그것이 곧 남의 볼트를 훑는 도구가 되기 때문입니다. 그래서
|
|
116
|
+
이 패키지도 추측하지 않고, 대신 확실히 아는 것만 말합니다.
|
|
117
|
+
|
|
118
|
+
| 예외 | 뜻 | 할 일 |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `MissingApiKeyError` | 환경변수가 비었다 | `/vault machine` 으로 키를 받는다 |
|
|
121
|
+
| `VaultUnreachableError` | 연결 자체가 안 됐다 | 터널이 떠 있는지 본다 |
|
|
122
|
+
| `SecretUnavailableError` | 볼트가 값을 주지 않았다 | 아래 셋을 순서대로 확인한다 |
|
|
123
|
+
| `UnexpectedResponseError` | 계약에 없는 응답이 왔다 | 주소가 정말 게이트웨이인지 확인한다 |
|
|
124
|
+
|
|
125
|
+
`SecretUnavailableError` 의 원인은 셋입니다.
|
|
126
|
+
|
|
127
|
+
1. **접근 그룹** — 그 시크릿에 접근 그룹이 지정돼 있지 않으면 머신은 값을 받지
|
|
128
|
+
못합니다. **가장 흔한 원인**이니 소유자에게 먼저 확인하세요.
|
|
129
|
+
2. **머신 키** — 아직 승인 전이거나 폐기됐을 수 있습니다.
|
|
130
|
+
3. **경로** — 프로젝트·환경·이름 중 하나가 틀렸을 수 있습니다.
|
|
131
|
+
|
|
132
|
+
네 예외는 모두 `VaultError` 를 상속하므로 한 번에 잡을 수 있습니다.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from chamelion_vault import VaultError
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
secret = get_secret("myproject", "dev", "EXAMPLE_KEY")
|
|
139
|
+
except VaultError as error:
|
|
140
|
+
print(error) # 무엇을 해야 하는지가 메시지에 들어 있습니다
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 받을 수 없는 것
|
|
144
|
+
|
|
145
|
+
- **개인 등급 시크릿.** 소유자 본인만 Slack 으로 받습니다. 머신에게는 열리지 않고,
|
|
146
|
+
관리자도 값을 볼 수 없습니다. 코드에서 써야 하는 값이라면 개인 등급으로 두면 안 됩니다.
|
|
147
|
+
- **프로젝트에 딸린 시크릿 목록.** 게이트웨이는 이름을 알고 있는 시크릿 한 건을 줄 뿐,
|
|
148
|
+
"이 프로젝트에 무엇이 있는지" 는 알려주지 않습니다. 목록은 Slack 에서 봅니다.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# ⚠️ 이 파일은 PyPI 에 그대로 실린다. 볼트 주소·조직·저장소 URL 을 적지 않는다.
|
|
2
|
+
# (저장소 URL 은 조직 이름을 드러낸다 — 사내 설치 방법은 사내 문서에 있다.)
|
|
3
|
+
[project]
|
|
4
|
+
name = "chamelion-vault"
|
|
5
|
+
version = "0.1.0"
|
|
6
|
+
description = "Client for a self-hosted chamelion vault gateway"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
# 개발자 노트북에서 돈다. 서버(3.12+)와 달리 넓게 잡는다 — 받는 쪽 프로젝트의
|
|
9
|
+
# 파이썬을 우리가 고르지 않는다.
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
# 의존성 0 (D33). `GET` 하나에 헤더 하나라 표준 라이브러리로 끝난다.
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/chamelion_vault"]
|
|
20
|
+
|
|
21
|
+
# 배포물에 소스와 README 만 담는다. 테스트는 저장소 루트의 `tests/` 에 있다 —
|
|
22
|
+
# 공개물 검사(금칙어 목록)가 그 안에 있어서, 함께 실으면 검사 대상이 곧 유출이 된다.
|
|
23
|
+
[tool.hatch.build.targets.sdist]
|
|
24
|
+
include = ["src/chamelion_vault", "README.md"]
|
|
25
|
+
# ⚠️ hatchling 은 저장소의 `.gitignore` 를 sdist 에 **강제로** 담는다(`exclude` 로도
|
|
26
|
+
# 빠지지 않는다). 우리 것은 일반적인 개발 무시 목록뿐이라 그대로 두되, 검사에서
|
|
27
|
+
# 빠지지 않게 `tests/test_client_package.py` 가 배포물의 **모든** 파일을 훑는다.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""chamelion vault — 머신 조회 클라이언트.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from chamelion_vault import get_secret
|
|
5
|
+
|
|
6
|
+
secret = get_secret("myproject", "dev", "EXAMPLE_KEY")
|
|
7
|
+
api_key = secret["value"].get_secret_value()
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**이 패키지는 서버 코드를 import 하지 않는다.** 같은 저장소에 있지만 다른 프로그램이고
|
|
11
|
+
남의 노트북에서 돈다 — 서버 타입을 가져다 쓰면 그 의존성(sqlalchemy·hvac)이 개발자의
|
|
12
|
+
프로젝트에 함께 설치된다. 공유하는 것은 코드가 아니라 **계약**이다.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from ._client import DEFAULT_URL, get_secret, get_secrets
|
|
18
|
+
from ._errors import (
|
|
19
|
+
KEY_ENV,
|
|
20
|
+
URL_ENV,
|
|
21
|
+
MissingApiKeyError,
|
|
22
|
+
SecretUnavailableError,
|
|
23
|
+
UnexpectedResponseError,
|
|
24
|
+
VaultError,
|
|
25
|
+
VaultUnreachableError,
|
|
26
|
+
)
|
|
27
|
+
from ._secret import Secret, SecretStr
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DEFAULT_URL",
|
|
31
|
+
"KEY_ENV",
|
|
32
|
+
"URL_ENV",
|
|
33
|
+
"MissingApiKeyError",
|
|
34
|
+
"Secret",
|
|
35
|
+
"SecretStr",
|
|
36
|
+
"SecretUnavailableError",
|
|
37
|
+
"UnexpectedResponseError",
|
|
38
|
+
"VaultError",
|
|
39
|
+
"VaultUnreachableError",
|
|
40
|
+
"get_secret",
|
|
41
|
+
"get_secrets",
|
|
42
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""값을 받아 오는 길 — `GET` 하나에 헤더 하나다.
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
환경변수에서 키·주소 → GET /v1/secrets/{project}/{secret_env}/{name}
|
|
5
|
+
→ JSON 파싱 → SecretStr 로 감싸 반환
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
**의존성이 없다.** 표준 라이브러리 `urllib.request` 로 끝난다 — 타임아웃도 재시도도
|
|
9
|
+
그것으로 되고, 사내 배포에서 의존성 0 이 가장 싸다(버전 충돌이 없다).
|
|
10
|
+
|
|
11
|
+
**값을 파일에 쓰지 않고 캐시하지 않는다.** 같은 값을 두 번 받으면 두 번 요청한다.
|
|
12
|
+
|
|
13
|
+
**로거를 만들지 않는다.** 이 패키지는 남의 애플리케이션 안에서 돈다 — 우리가 로깅을
|
|
14
|
+
설정하면 그 앱의 설정을 덮어쓴다. 값이 새는 것을 막는 가장 확실한 방법이기도 하다.
|
|
15
|
+
|
|
16
|
+
원인 예외를 `from None` 으로 끊는다. `urllib` 의 프레임을 그대로 달고 올라가면
|
|
17
|
+
요청 객체(헤더에 키가 들어 있다)가 트레이스백의 지역 변수로 노출될 수 있다.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import quote
|
|
29
|
+
|
|
30
|
+
from ._errors import (
|
|
31
|
+
KEY_ENV,
|
|
32
|
+
URL_ENV,
|
|
33
|
+
MissingApiKeyError,
|
|
34
|
+
SecretUnavailableError,
|
|
35
|
+
UnexpectedResponseError,
|
|
36
|
+
VaultUnreachableError,
|
|
37
|
+
)
|
|
38
|
+
from ._secret import Secret, SecretStr
|
|
39
|
+
|
|
40
|
+
DEFAULT_URL = "http://127.0.0.1:8081"
|
|
41
|
+
"""기본 주소 — **터널의 로컬 종단**이다. 볼트 호스트의 주소가 아니다.
|
|
42
|
+
|
|
43
|
+
터널을 쓰는 사람이 아무것도 설정하지 않아도 되게 하는 값이고, 이 주소로 나간 요청은
|
|
44
|
+
`ssh -L` 이 만든 통로가 없으면 아무 데도 닿지 않는다.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
API_KEY_HEADER = "X-API-Key"
|
|
48
|
+
"""자격증명은 헤더로만 보낸다. URL 에 넣으면 셸 히스토리·프록시 로그에 원문이 남는다."""
|
|
49
|
+
|
|
50
|
+
TIMEOUT_SECONDS = 10.0
|
|
51
|
+
"""터널을 한 번 왕복하기에 충분하고, 터널이 죽었을 때 오래 매달리지 않는 구간."""
|
|
52
|
+
|
|
53
|
+
_SECRET_ROUTE = "/v1/secrets"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_secret(project: str, secret_env: str, name: str) -> Secret:
|
|
57
|
+
"""시크릿 1건을 받는다. **값은 `SecretStr` 로 감싸여 온다.**
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
project: 프로젝트 이름.
|
|
61
|
+
secret_env: 시크릿 환경 (`local`·`dev`·`staging`·`prod`).
|
|
62
|
+
name: 시크릿 이름.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
경로와 필드를 담은 `Secret`. 값 하나짜리 시크릿은 `["value"]` 에 있다.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
MissingApiKeyError: 환경변수에 머신 API 키가 없는 경우.
|
|
69
|
+
VaultUnreachableError: 게이트웨이에 연결하지 못한 경우(대개 터널이 없다).
|
|
70
|
+
SecretUnavailableError: 서버가 값을 주지 않은 경우. **원인은 알 수 없다.**
|
|
71
|
+
UnexpectedResponseError: 계약에 없는 응답이 온 경우.
|
|
72
|
+
"""
|
|
73
|
+
base = os.environ.get(URL_ENV, "").strip() or DEFAULT_URL
|
|
74
|
+
key = os.environ.get(KEY_ENV, "").strip()
|
|
75
|
+
if not key:
|
|
76
|
+
raise MissingApiKeyError()
|
|
77
|
+
|
|
78
|
+
path = _join(project, secret_env, name)
|
|
79
|
+
url = f"{base.rstrip('/')}{_SECRET_ROUTE}/{path}"
|
|
80
|
+
payload = _fetch(url=url, key=key, path=path)
|
|
81
|
+
return _to_secret(url=url, payload=payload, fallback_path=path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_secrets(project: str, secret_env: str, names: Iterable[str]) -> dict[str, Secret]:
|
|
85
|
+
"""같은 프로젝트·환경의 시크릿 여러 건을 받는다.
|
|
86
|
+
|
|
87
|
+
**서버 계약은 경로 하나에 요청 하나다.** 이 함수는 그 요청을 순서대로 반복할 뿐이고,
|
|
88
|
+
감사 로그에도 건별로 남는다. 하나라도 실패하면 **전부 실패로 끝낸다** — 반쪽짜리
|
|
89
|
+
설정으로 애플리케이션이 뜨는 것보다 뜨지 않는 것이 낫다.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
project: 프로젝트 이름.
|
|
93
|
+
secret_env: 시크릿 환경.
|
|
94
|
+
names: 받을 시크릿 이름들.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
시크릿 이름 → `Secret`.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
MissingApiKeyError: 환경변수에 머신 API 키가 없는 경우.
|
|
101
|
+
VaultUnreachableError: 게이트웨이에 연결하지 못한 경우.
|
|
102
|
+
SecretUnavailableError: 한 건이라도 값을 받지 못한 경우.
|
|
103
|
+
UnexpectedResponseError: 계약에 없는 응답이 온 경우.
|
|
104
|
+
"""
|
|
105
|
+
return {name: get_secret(project, secret_env, name) for name in names}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _join(project: str, secret_env: str, name: str) -> str:
|
|
109
|
+
"""경로 세그먼트 셋을 잇는다. 값을 다루지 않는다.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ValueError: 세그먼트가 비어 있는 경우.
|
|
113
|
+
"""
|
|
114
|
+
segments = (project, secret_env, name)
|
|
115
|
+
if not all(segment.strip() for segment in segments):
|
|
116
|
+
raise ValueError("프로젝트·환경·이름은 모두 있어야 합니다")
|
|
117
|
+
return "/".join(quote(segment.strip(), safe="") for segment in segments)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fetch(*, url: str, key: str, path: str) -> Any:
|
|
121
|
+
"""요청 한 번을 보내고 본문을 파싱한다. **본문을 로깅하지 않는다.**
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
VaultUnreachableError: 연결·타임아웃 실패.
|
|
125
|
+
SecretUnavailableError: `404`.
|
|
126
|
+
UnexpectedResponseError: 그 밖의 응답이거나 본문이 JSON 이 아닌 경우.
|
|
127
|
+
"""
|
|
128
|
+
request = urllib.request.Request(url, headers={API_KEY_HEADER: key}, method="GET")
|
|
129
|
+
try:
|
|
130
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
|
131
|
+
body = response.read()
|
|
132
|
+
except urllib.error.HTTPError as error:
|
|
133
|
+
status = error.code
|
|
134
|
+
error.close()
|
|
135
|
+
if status == 404:
|
|
136
|
+
raise SecretUnavailableError(path=path) from None
|
|
137
|
+
raise UnexpectedResponseError(url=url, detail=f"HTTP {status}") from None
|
|
138
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
139
|
+
raise VaultUnreachableError(url=url) from None
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return json.loads(body.decode("utf-8"))
|
|
143
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
144
|
+
# 본문을 오류에 담지 않는다 — 그 본문이 값일 수 있다.
|
|
145
|
+
raise UnexpectedResponseError(url=url, detail="본문이 JSON 이 아닙니다") from None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _to_secret(*, url: str, payload: Any, fallback_path: str) -> Secret:
|
|
149
|
+
"""계약대로 온 본문을 `Secret` 으로 옮긴다. 여기서 값이 상자에 들어간다.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
UnexpectedResponseError: 본문 모양이 계약과 다른 경우.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(payload, dict):
|
|
155
|
+
raise UnexpectedResponseError(url=url, detail="본문이 객체가 아닙니다")
|
|
156
|
+
|
|
157
|
+
fields = payload.get("fields")
|
|
158
|
+
if not isinstance(fields, dict) or not fields:
|
|
159
|
+
raise UnexpectedResponseError(url=url, detail="본문에 fields 가 없습니다")
|
|
160
|
+
if not all(isinstance(value, str) for value in fields.values()):
|
|
161
|
+
raise UnexpectedResponseError(url=url, detail="fields 값이 문자열이 아닙니다")
|
|
162
|
+
|
|
163
|
+
path = payload.get("path")
|
|
164
|
+
return Secret(
|
|
165
|
+
path=path if isinstance(path, str) else fallback_path,
|
|
166
|
+
fields={name: SecretStr(value) for name, value in fields.items()},
|
|
167
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""오류 — 개발자가 **다음에 무엇을 해야 하는지**를 담는다.
|
|
2
|
+
|
|
3
|
+
서버는 실패 원인을 알려주지 않는다. 키가 틀렸는가·권한이 없는가·그 시크릿이 없는가를
|
|
4
|
+
응답으로 가르면 그것이 곧 탐색 도구가 되기 때문이다. 그래서 이 파일은 **추측하지
|
|
5
|
+
않는다** — 대신 클라이언트가 확실히 아는 것(어디까지 갔는가)으로 셋을 가른다.
|
|
6
|
+
|
|
7
|
+
| 실패 | 아는 것 | 문안이 하는 일 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| 연결 자체가 안 됨 | 터널이 없다 | `ssh` 명령을 그대로 보여준다 |
|
|
10
|
+
| 환경변수가 비었다 | 키가 없다 | 어디서 받는지 알려준다 |
|
|
11
|
+
| `404` | 그 이상 모른다 | 가능한 원인 셋을 나열한다 |
|
|
12
|
+
|
|
13
|
+
**예외 메시지에 값도 키도 넣지 않는다.** 밖으로 나가는 것은 경로·환경변수 이름·
|
|
14
|
+
설정된 주소뿐이고, 셋 다 비밀이 아니다.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
KEY_ENV = "CHAMELION_VAULT_KEY"
|
|
22
|
+
"""머신 API 키를 받는 환경변수. **인자로 키를 받지 않는다** — 만들면 소스에 박힌다."""
|
|
23
|
+
|
|
24
|
+
URL_ENV = "CHAMELION_VAULT_URL"
|
|
25
|
+
"""게이트웨이 주소를 덮어쓰는 환경변수. 비어 있으면 터널의 로컬 종단을 쓴다."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class VaultError(Exception):
|
|
29
|
+
"""이 패키지가 내는 모든 오류의 뿌리."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MissingApiKeyError(VaultError):
|
|
33
|
+
"""환경변수에 머신 API 키가 없다."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
super().__init__(
|
|
37
|
+
f"머신 API 키가 없습니다. 환경변수 {KEY_ENV} 에 넣어 주세요.\n"
|
|
38
|
+
"\n"
|
|
39
|
+
"키는 Slack 에서 받습니다.\n"
|
|
40
|
+
"\n"
|
|
41
|
+
" /vault machine\n"
|
|
42
|
+
"\n"
|
|
43
|
+
"관리자 승인 뒤 한 번만 표시되므로, 잃어버렸다면 다시 요청해 주세요."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class VaultUnreachableError(VaultError):
|
|
48
|
+
"""게이트웨이에 연결하지 못했다. 대개 터널이 떠 있지 않다."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, *, url: str) -> None:
|
|
51
|
+
port = urlsplit(url).port or 8081
|
|
52
|
+
super().__init__(
|
|
53
|
+
f"볼트에 연결하지 못했습니다 ({url}).\n"
|
|
54
|
+
"SSH 터널이 떠 있는지 확인해 주세요.\n"
|
|
55
|
+
"\n"
|
|
56
|
+
" ssh -i <터널 키> -o IdentitiesOnly=yes -p <SSH 포트> \\\n"
|
|
57
|
+
f" -N -L {port}:127.0.0.1:{port} <계정>@<볼트 호스트>\n"
|
|
58
|
+
"\n"
|
|
59
|
+
"SSH 포트·계정·볼트 호스트는 사내 터널 문서에 있습니다.\n"
|
|
60
|
+
"IdentitiesOnly=yes 를 빠뜨리면 다른 키로 인증돼 연결이 거부됩니다."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SecretUnavailableError(VaultError):
|
|
65
|
+
"""서버가 값을 주지 않았다. **원인은 서버만 안다.**"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, path: str) -> None:
|
|
68
|
+
super().__init__(
|
|
69
|
+
f"값을 받지 못했습니다: {path}\n"
|
|
70
|
+
"볼트는 이유를 알려주지 않습니다. 가능한 원인은 셋입니다.\n"
|
|
71
|
+
"\n"
|
|
72
|
+
" 1. 접근 그룹 — 이 시크릿에 접근 그룹이 지정돼 있지 않으면 머신은\n"
|
|
73
|
+
" 값을 받지 못합니다. 가장 흔한 원인이니 소유자에게 먼저 확인해 주세요.\n"
|
|
74
|
+
" 2. 머신 키 — 아직 승인 전이거나 폐기됐을 수 있습니다.\n"
|
|
75
|
+
" 3. 경로 — 프로젝트·환경·이름 중 하나가 틀렸을 수 있습니다.\n"
|
|
76
|
+
"\n"
|
|
77
|
+
"개인 등급 시크릿은 머신이 받을 수 없습니다. 소유자 본인만 Slack 으로 받습니다."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class UnexpectedResponseError(VaultError):
|
|
82
|
+
"""계약에 없는 응답이 왔다. 서버 쪽 사고이거나 중간에 다른 것이 답한 것이다."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, *, url: str, detail: str) -> None:
|
|
85
|
+
super().__init__(
|
|
86
|
+
f"볼트가 예상하지 못한 응답을 보냈습니다 ({url}): {detail}\n"
|
|
87
|
+
"이 주소가 정말 볼트 게이트웨이인지, 관리자에게 확인해 주세요."
|
|
88
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""받은 값을 담는 두 타입 — `SecretStr` 와 `Secret`.
|
|
2
|
+
|
|
3
|
+
**값은 상자에 담겨 나간다.** 실수로 로그·예외·`print` 에 통째로 섞여도 `<REDACTED>` 만
|
|
4
|
+
남고, 실제로 쓰려면 `get_secret_value()` 로 한 번 열어야 한다. 여는 순간부터는
|
|
5
|
+
받는 쪽 프로그램의 책임이다 — 우리가 막을 수 있는 것은 **실수로 찍는 경우**뿐이다.
|
|
6
|
+
|
|
7
|
+
서버 쪽 규칙(rules/secrets_handling.md §3)을 클라이언트에도 그대로 적용한 것이고,
|
|
8
|
+
pydantic 의 `SecretStr` 을 쓰지 않은 이유는 **의존성을 0으로 두기 위해서**다(D33).
|
|
9
|
+
마스킹은 암호가 아니므로 "암호 알고리즘을 직접 구현하지 않는다" 와 무관하다.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator, Mapping
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SecretStr:
|
|
18
|
+
"""평문 시크릿 값 래퍼. `repr`·`str`·`format` 에서 값이 새지 않게 마스킹한다."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("_value",)
|
|
21
|
+
|
|
22
|
+
def __init__(self, value: str) -> None:
|
|
23
|
+
self._value = value
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return "SecretStr(<REDACTED>)"
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
return "<REDACTED>"
|
|
30
|
+
|
|
31
|
+
def __format__(self, format_spec: str) -> str:
|
|
32
|
+
return "<REDACTED>"
|
|
33
|
+
|
|
34
|
+
def get_secret_value(self) -> str:
|
|
35
|
+
"""실제 값을 꺼낸다. 꺼낸 뒤에는 즉시 쓰고 참조를 버린다."""
|
|
36
|
+
return self._value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Secret(Mapping[str, SecretStr]):
|
|
40
|
+
"""시크릿 1건 — 경로 하나와 그 안의 필드들.
|
|
41
|
+
|
|
42
|
+
값이 하나뿐인 시크릿도 필드 하나짜리 묶음이다(`["value"]`). 한 몸인 크리덴셜을
|
|
43
|
+
한 경로에 여러 필드로 둔 것이 번들이고, 그때 필드가 여럿이 된다.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("_fields", "_path")
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, path: str, fields: Mapping[str, SecretStr]) -> None:
|
|
49
|
+
"""경로와 필드를 받는다.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
path: 서버가 알려준 시크릿 경로. 비밀이 아니다.
|
|
53
|
+
fields: 필드 이름 → 값. 값은 이미 `SecretStr` 로 감싸여 있다.
|
|
54
|
+
"""
|
|
55
|
+
self._path = path
|
|
56
|
+
self._fields = dict(fields)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def path(self) -> str:
|
|
60
|
+
"""시크릿 경로. 값이 아니므로 로그에 남겨도 된다."""
|
|
61
|
+
return self._path
|
|
62
|
+
|
|
63
|
+
def __getitem__(self, key: str) -> SecretStr:
|
|
64
|
+
return self._fields[key]
|
|
65
|
+
|
|
66
|
+
def __iter__(self) -> Iterator[str]:
|
|
67
|
+
return iter(self._fields)
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
return len(self._fields)
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
# 경로와 **필드 이름**은 비밀이 아니다. 값은 여기에 등장하지 않는다.
|
|
74
|
+
return f"Secret({self._path}, fields={sorted(self._fields)})"
|
|
File without changes
|