yoink-py 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.
- yoink_py-0.1.0/LICENSE +22 -0
- yoink_py-0.1.0/PKG-INFO +126 -0
- yoink_py-0.1.0/README.md +103 -0
- yoink_py-0.1.0/pyproject.toml +35 -0
- yoink_py-0.1.0/setup.cfg +4 -0
- yoink_py-0.1.0/yoink/__init__.py +1 -0
- yoink_py-0.1.0/yoink/access.py +268 -0
- yoink_py-0.1.0/yoink/cli.py +303 -0
- yoink_py-0.1.0/yoink/crypto.py +64 -0
- yoink_py-0.1.0/yoink/identity.py +90 -0
- yoink_py-0.1.0/yoink/secrets.py +139 -0
- yoink_py-0.1.0/yoink/store.py +142 -0
- yoink_py-0.1.0/yoink_py.egg-info/PKG-INFO +126 -0
- yoink_py-0.1.0/yoink_py.egg-info/SOURCES.txt +16 -0
- yoink_py-0.1.0/yoink_py.egg-info/dependency_links.txt +1 -0
- yoink_py-0.1.0/yoink_py.egg-info/entry_points.txt +2 -0
- yoink_py-0.1.0/yoink_py.egg-info/requires.txt +1 -0
- yoink_py-0.1.0/yoink_py.egg-info/top_level.txt +1 -0
yoink_py-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) $YEAR yoink contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
yoink_py-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yoink-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Proof-of-concept: encrypted repo-native secrets for developer teams
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/jack-kitto/yoink-py
|
|
7
|
+
Project-URL: Issues, https://github.com/jack-kitto/yoink-py/issues
|
|
8
|
+
Keywords: secrets,encryption,age,devtools,dotenv
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Yoink
|
|
25
|
+
|
|
26
|
+
Lean, repo-native secrets for developer teams.
|
|
27
|
+
|
|
28
|
+
Encrypted secrets live in your repo. Developers decrypt only what they have access to.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.9+
|
|
33
|
+
- [age](https://github.com/FiloSottile/age) (`brew install age`)
|
|
34
|
+
- Git
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install yoink-py
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
yoink secrets Edit all secrets in $EDITOR
|
|
46
|
+
yoink access edit Review members and requests in $EDITOR
|
|
47
|
+
yoink access request Request access to the vault (new developers)
|
|
48
|
+
yoink run <env> -- <cmd> Run a command with secrets injected
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd your-repo
|
|
55
|
+
yoink secrets # bootstraps vault on first run, then opens editor
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The vault is created in `.yoink/` with `dev`, `staging`, and `production` environments.
|
|
59
|
+
Two vault-wide recovery keys are printed once — back them up in your team password manager.
|
|
60
|
+
|
|
61
|
+
## Secrets editor
|
|
62
|
+
|
|
63
|
+
`yoink secrets` opens a buffer like:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
[dev]
|
|
67
|
+
DATABASE_URL=postgres://localhost/mydb
|
|
68
|
+
API_KEY=sk_test_abc
|
|
69
|
+
|
|
70
|
+
[staging]
|
|
71
|
+
DATABASE_URL=postgres://staging/mydb
|
|
72
|
+
|
|
73
|
+
[production]
|
|
74
|
+
DATABASE_URL=postgres://prod/mydb
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- Edit values inline
|
|
78
|
+
- Add a key to add it
|
|
79
|
+
- Delete a line to remove a secret
|
|
80
|
+
- Add a new `[environment]` header to create a new environment
|
|
81
|
+
- Save and quit — changes are applied
|
|
82
|
+
|
|
83
|
+
## Access editor
|
|
84
|
+
|
|
85
|
+
`yoink access edit` opens a buffer like:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
## members
|
|
89
|
+
jack dev staging production
|
|
90
|
+
sarah dev staging
|
|
91
|
+
|
|
92
|
+
## requests
|
|
93
|
+
bob dev staging
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- Edit the environment list on a member line to change their access
|
|
97
|
+
- Delete a member line to revoke their access
|
|
98
|
+
- Move a request line above `## requests` to approve it
|
|
99
|
+
- Delete a request line to reject it
|
|
100
|
+
- Save and quit — changes are applied
|
|
101
|
+
|
|
102
|
+
## New developer workflow
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
yoink access request # generates keypair, writes .yoink/requests/<you>.json
|
|
106
|
+
git add .yoink/requests/<you>.json
|
|
107
|
+
git commit -m "access request: <you>"
|
|
108
|
+
# open a PR
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A maintainer pulls the PR and runs `yoink access edit`. Moving your line above
|
|
112
|
+
`## requests` and saving approves you. The vault files are re-encrypted to include
|
|
113
|
+
your key.
|
|
114
|
+
|
|
115
|
+
## How it works
|
|
116
|
+
|
|
117
|
+
- Secrets are encrypted with [age](https://github.com/FiloSottile/age) and stored as `.enc` files in `.yoink/`
|
|
118
|
+
- Each developer has an identity keypair in `~/.yoink/`
|
|
119
|
+
- The manifest (`manifest.json`) tracks who has access to what
|
|
120
|
+
- Re-encryption happens automatically when access changes
|
|
121
|
+
|
|
122
|
+
## Limitations
|
|
123
|
+
|
|
124
|
+
- Git history is immutable — revoking access doesn't erase past exposure
|
|
125
|
+
- No runtime audit — who decrypted what and when is not tracked
|
|
126
|
+
- Best for small-to-medium teams
|
yoink_py-0.1.0/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Yoink
|
|
2
|
+
|
|
3
|
+
Lean, repo-native secrets for developer teams.
|
|
4
|
+
|
|
5
|
+
Encrypted secrets live in your repo. Developers decrypt only what they have access to.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.9+
|
|
10
|
+
- [age](https://github.com/FiloSottile/age) (`brew install age`)
|
|
11
|
+
- Git
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install yoink-py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
yoink secrets Edit all secrets in $EDITOR
|
|
23
|
+
yoink access edit Review members and requests in $EDITOR
|
|
24
|
+
yoink access request Request access to the vault (new developers)
|
|
25
|
+
yoink run <env> -- <cmd> Run a command with secrets injected
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd your-repo
|
|
32
|
+
yoink secrets # bootstraps vault on first run, then opens editor
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The vault is created in `.yoink/` with `dev`, `staging`, and `production` environments.
|
|
36
|
+
Two vault-wide recovery keys are printed once — back them up in your team password manager.
|
|
37
|
+
|
|
38
|
+
## Secrets editor
|
|
39
|
+
|
|
40
|
+
`yoink secrets` opens a buffer like:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
[dev]
|
|
44
|
+
DATABASE_URL=postgres://localhost/mydb
|
|
45
|
+
API_KEY=sk_test_abc
|
|
46
|
+
|
|
47
|
+
[staging]
|
|
48
|
+
DATABASE_URL=postgres://staging/mydb
|
|
49
|
+
|
|
50
|
+
[production]
|
|
51
|
+
DATABASE_URL=postgres://prod/mydb
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- Edit values inline
|
|
55
|
+
- Add a key to add it
|
|
56
|
+
- Delete a line to remove a secret
|
|
57
|
+
- Add a new `[environment]` header to create a new environment
|
|
58
|
+
- Save and quit — changes are applied
|
|
59
|
+
|
|
60
|
+
## Access editor
|
|
61
|
+
|
|
62
|
+
`yoink access edit` opens a buffer like:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
## members
|
|
66
|
+
jack dev staging production
|
|
67
|
+
sarah dev staging
|
|
68
|
+
|
|
69
|
+
## requests
|
|
70
|
+
bob dev staging
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- Edit the environment list on a member line to change their access
|
|
74
|
+
- Delete a member line to revoke their access
|
|
75
|
+
- Move a request line above `## requests` to approve it
|
|
76
|
+
- Delete a request line to reject it
|
|
77
|
+
- Save and quit — changes are applied
|
|
78
|
+
|
|
79
|
+
## New developer workflow
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
yoink access request # generates keypair, writes .yoink/requests/<you>.json
|
|
83
|
+
git add .yoink/requests/<you>.json
|
|
84
|
+
git commit -m "access request: <you>"
|
|
85
|
+
# open a PR
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A maintainer pulls the PR and runs `yoink access edit`. Moving your line above
|
|
89
|
+
`## requests` and saving approves you. The vault files are re-encrypted to include
|
|
90
|
+
your key.
|
|
91
|
+
|
|
92
|
+
## How it works
|
|
93
|
+
|
|
94
|
+
- Secrets are encrypted with [age](https://github.com/FiloSottile/age) and stored as `.enc` files in `.yoink/`
|
|
95
|
+
- Each developer has an identity keypair in `~/.yoink/`
|
|
96
|
+
- The manifest (`manifest.json`) tracks who has access to what
|
|
97
|
+
- Re-encryption happens automatically when access changes
|
|
98
|
+
|
|
99
|
+
## Limitations
|
|
100
|
+
|
|
101
|
+
- Git history is immutable — revoking access doesn't erase past exposure
|
|
102
|
+
- No runtime audit — who decrypted what and when is not tracked
|
|
103
|
+
- Best for small-to-medium teams
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yoink-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Proof-of-concept: encrypted repo-native secrets for developer teams"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = ["click>=8.1"]
|
|
13
|
+
keywords = ["secrets", "encryption", "age", "devtools", "dotenv"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Security :: Cryptography",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/jack-kitto/yoink-py"
|
|
28
|
+
Issues = "https://github.com/jack-kitto/yoink-py/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
yoink = "yoink.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["."]
|
|
35
|
+
include = ["yoink*"]
|
yoink_py-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Access — request workflow and the access editor buffer."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .crypto import reencrypt_file, encrypt_file
|
|
9
|
+
from .identity import Identity, create_identity, create_recovery_identity
|
|
10
|
+
from .store import Manifest, find_enc_files, get_git_username, requests_dir, vault_dir, global_dir
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Request creation (run by new dev)
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def create_request(username: str, environments: list[str]) -> tuple[str, str]:
|
|
17
|
+
"""Generate keypair + recovery key, write request JSON.
|
|
18
|
+
|
|
19
|
+
Returns (request_path, recovery_secret_key) so CLI can print the key.
|
|
20
|
+
"""
|
|
21
|
+
from .store import YOINK_DIR
|
|
22
|
+
identity = create_identity(username)
|
|
23
|
+
recovery, recovery_secret = create_recovery_identity(f"{username}-recovery")
|
|
24
|
+
|
|
25
|
+
data = {
|
|
26
|
+
"identity": username,
|
|
27
|
+
"public_key": identity.public_key,
|
|
28
|
+
"recovery_public_key": recovery.public_key,
|
|
29
|
+
"environments": environments,
|
|
30
|
+
"requested_at": datetime.now(timezone.utc).isoformat(),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
rdir = requests_dir()
|
|
34
|
+
rdir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path = rdir / f"{username}.json"
|
|
36
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
37
|
+
|
|
38
|
+
return str(path), recovery_secret
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_requests() -> list[dict]:
|
|
42
|
+
rdir = requests_dir()
|
|
43
|
+
if not rdir.exists():
|
|
44
|
+
return []
|
|
45
|
+
return [
|
|
46
|
+
json.loads(f.read_text()) | {"_path": str(f)}
|
|
47
|
+
for f in sorted(rdir.glob("*.json"))
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Buffer render / parse
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
HEADER = """\
|
|
56
|
+
# Access management
|
|
57
|
+
# members: edit environment list to change access, delete line to revoke
|
|
58
|
+
# requests: move a line above '## requests' to approve, delete to reject
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_buffer(manifest: Manifest) -> str:
|
|
63
|
+
lines = [HEADER, "## members"]
|
|
64
|
+
|
|
65
|
+
for name, info in sorted(manifest.get_identities().items()):
|
|
66
|
+
envs = _envs_for_identity(name, manifest)
|
|
67
|
+
lines.append(f"{name} {' '.join(sorted(envs))}")
|
|
68
|
+
|
|
69
|
+
lines += ["", "## requests"]
|
|
70
|
+
|
|
71
|
+
for req in load_requests():
|
|
72
|
+
envs = " ".join(req.get("environments", []))
|
|
73
|
+
lines.append(f"{req['identity']} {envs}")
|
|
74
|
+
|
|
75
|
+
return "\n".join(lines) + "\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _envs_for_identity(name: str, manifest: Manifest) -> list[str]:
|
|
79
|
+
pk = manifest.get_identity_key(name)
|
|
80
|
+
if not pk:
|
|
81
|
+
return []
|
|
82
|
+
return [
|
|
83
|
+
env for env, info in manifest.get_envs().items()
|
|
84
|
+
if pk in info.get("recipients", [])
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Parse buffer
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def parse_buffer(text: str) -> tuple[dict[str, list[str]], list[str]]:
|
|
93
|
+
"""Parse access buffer.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
members: {username: [env, ...]}
|
|
97
|
+
rejected: [username, ...] — requests that were deleted
|
|
98
|
+
"""
|
|
99
|
+
members: dict[str, list[str]] = {}
|
|
100
|
+
in_members = False
|
|
101
|
+
in_requests = False
|
|
102
|
+
seen_requests: set[str] = set()
|
|
103
|
+
|
|
104
|
+
for line in text.splitlines():
|
|
105
|
+
stripped = line.strip()
|
|
106
|
+
if not stripped:
|
|
107
|
+
continue
|
|
108
|
+
# Skip comment lines but not section headers
|
|
109
|
+
if stripped.startswith("#") and stripped not in ("## members", "## requests"):
|
|
110
|
+
continue
|
|
111
|
+
if stripped == "## members":
|
|
112
|
+
in_members = True
|
|
113
|
+
in_requests = False
|
|
114
|
+
continue
|
|
115
|
+
if stripped == "## requests":
|
|
116
|
+
in_members = False
|
|
117
|
+
in_requests = True
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
parts = stripped.split()
|
|
121
|
+
if not parts:
|
|
122
|
+
continue
|
|
123
|
+
name = parts[0]
|
|
124
|
+
envs = parts[1:]
|
|
125
|
+
|
|
126
|
+
if in_members:
|
|
127
|
+
members[name] = envs
|
|
128
|
+
elif in_requests:
|
|
129
|
+
seen_requests.add(name)
|
|
130
|
+
|
|
131
|
+
# Requests not present in buffer were deleted → rejected
|
|
132
|
+
all_requests = {r["identity"] for r in load_requests()}
|
|
133
|
+
rejected = [r for r in all_requests if r not in seen_requests and r not in members]
|
|
134
|
+
|
|
135
|
+
return members, rejected
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Apply buffer changes
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def apply_access(
|
|
143
|
+
members: dict[str, list[str]],
|
|
144
|
+
rejected: list[str],
|
|
145
|
+
manifest: Manifest,
|
|
146
|
+
identity: Identity,
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
"""Reconcile parsed buffer against current manifest state.
|
|
149
|
+
|
|
150
|
+
- New member (was a request, now in members) → approve
|
|
151
|
+
- Removed member → revoke
|
|
152
|
+
- Changed envs → re-encrypt accordingly
|
|
153
|
+
- Rejected requests → delete request file
|
|
154
|
+
Returns list of changes made.
|
|
155
|
+
"""
|
|
156
|
+
changes = []
|
|
157
|
+
requests_by_name = {r["identity"]: r for r in load_requests()}
|
|
158
|
+
vdir = vault_dir()
|
|
159
|
+
all_envs = list(manifest.get_envs())
|
|
160
|
+
|
|
161
|
+
# --- Approvals: name in members but not yet in manifest identities ---
|
|
162
|
+
for name, envs in members.items():
|
|
163
|
+
if not manifest.has_identity(name):
|
|
164
|
+
req = requests_by_name.get(name)
|
|
165
|
+
if not req:
|
|
166
|
+
changes.append(f"WARNING: no request found for '{name}', skipping")
|
|
167
|
+
continue
|
|
168
|
+
_approve(name, req, envs, manifest, identity, vdir)
|
|
169
|
+
changes.append(f"approved {name} → {' '.join(envs) or '(no envs)'}")
|
|
170
|
+
|
|
171
|
+
# --- Revocations: name in manifest but not in new members ---
|
|
172
|
+
for name in list(manifest.get_identities()):
|
|
173
|
+
if name not in members:
|
|
174
|
+
_revoke(name, manifest, identity, vdir)
|
|
175
|
+
changes.append(f"revoked {name}")
|
|
176
|
+
|
|
177
|
+
# --- Env changes: name in both, but envs differ ---
|
|
178
|
+
for name, new_envs in members.items():
|
|
179
|
+
if not manifest.has_identity(name):
|
|
180
|
+
continue # just approved above
|
|
181
|
+
old_envs = set(_envs_for_identity(name, manifest))
|
|
182
|
+
new_set = set(new_envs)
|
|
183
|
+
if old_envs == new_set:
|
|
184
|
+
continue
|
|
185
|
+
pk = manifest.get_identity_key(name)
|
|
186
|
+
user_info = manifest.get_identities()[name]
|
|
187
|
+
recovery_key = user_info.get("recovery_key")
|
|
188
|
+
|
|
189
|
+
# Grant access to added envs
|
|
190
|
+
for env in new_set - old_envs:
|
|
191
|
+
recipients = manifest.get_recipients(env)
|
|
192
|
+
if pk not in recipients:
|
|
193
|
+
recipients = recipients + [pk]
|
|
194
|
+
if recovery_key and recovery_key not in recipients:
|
|
195
|
+
recipients.append(recovery_key)
|
|
196
|
+
enc = vdir / env / ".env.enc"
|
|
197
|
+
if enc.exists():
|
|
198
|
+
reencrypt_file(enc, recipients, identity.key_file)
|
|
199
|
+
manifest.set_recipients(env, recipients)
|
|
200
|
+
changes.append(f"granted {name} access to [{env}]")
|
|
201
|
+
|
|
202
|
+
# Revoke access from removed envs
|
|
203
|
+
for env in old_envs - new_set:
|
|
204
|
+
recipients = [
|
|
205
|
+
r for r in manifest.get_recipients(env)
|
|
206
|
+
if r != pk and r != recovery_key
|
|
207
|
+
]
|
|
208
|
+
enc = vdir / env / ".env.enc"
|
|
209
|
+
if enc.exists():
|
|
210
|
+
reencrypt_file(enc, recipients, identity.key_file)
|
|
211
|
+
manifest.set_recipients(env, recipients)
|
|
212
|
+
changes.append(f"revoked {name} from [{env}]")
|
|
213
|
+
|
|
214
|
+
# --- Rejections ---
|
|
215
|
+
for name in rejected:
|
|
216
|
+
req = requests_by_name.get(name)
|
|
217
|
+
if req and req.get("_path"):
|
|
218
|
+
Path(req["_path"]).unlink(missing_ok=True)
|
|
219
|
+
changes.append(f"rejected request from {name}")
|
|
220
|
+
|
|
221
|
+
manifest.save()
|
|
222
|
+
return changes
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _approve(
|
|
226
|
+
name: str,
|
|
227
|
+
req: dict,
|
|
228
|
+
envs: list[str],
|
|
229
|
+
manifest: Manifest,
|
|
230
|
+
identity: Identity,
|
|
231
|
+
vdir: Path,
|
|
232
|
+
) -> None:
|
|
233
|
+
pk = req["public_key"]
|
|
234
|
+
recovery_key = req.get("recovery_public_key")
|
|
235
|
+
|
|
236
|
+
for env in envs:
|
|
237
|
+
recipients = manifest.get_recipients(env)
|
|
238
|
+
if pk not in recipients:
|
|
239
|
+
recipients = recipients + [pk]
|
|
240
|
+
if recovery_key and recovery_key not in recipients:
|
|
241
|
+
recipients.append(recovery_key)
|
|
242
|
+
enc = vdir / env / ".env.enc"
|
|
243
|
+
if enc.exists():
|
|
244
|
+
reencrypt_file(enc, recipients, identity.key_file)
|
|
245
|
+
manifest.set_recipients(env, recipients)
|
|
246
|
+
|
|
247
|
+
manifest.add_identity(name, pk, recovery_key=recovery_key)
|
|
248
|
+
|
|
249
|
+
# Remove request file
|
|
250
|
+
if req.get("_path"):
|
|
251
|
+
Path(req["_path"]).unlink(missing_ok=True)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _revoke(name: str, manifest: Manifest, identity: Identity, vdir: Path) -> None:
|
|
255
|
+
user_info = manifest.get_identities().get(name, {})
|
|
256
|
+
pk = user_info.get("public_key")
|
|
257
|
+
recovery_key = user_info.get("recovery_key")
|
|
258
|
+
|
|
259
|
+
for env, info in manifest.get_envs().items():
|
|
260
|
+
recipients = info.get("recipients", [])
|
|
261
|
+
updated = [r for r in recipients if r != pk and r != recovery_key]
|
|
262
|
+
if updated != recipients:
|
|
263
|
+
enc = vdir / env / ".env.enc"
|
|
264
|
+
if enc.exists():
|
|
265
|
+
reencrypt_file(enc, updated, identity.key_file)
|
|
266
|
+
manifest.set_recipients(env, updated)
|
|
267
|
+
|
|
268
|
+
manifest.remove_identity(name)
|