@dooer/dooer-test-env 1.12.0 → 1.14.0
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.
- package/docs/getting-access.md +52 -116
- package/docs/issuing-access.md +141 -50
- package/lib/cli.js +2 -0
- package/lib/command/partner.js +120 -0
- package/lib/command/target.js +31 -0
- package/lib/command/user.js +172 -0
- package/lib/users.js +285 -0
- package/package.json +2 -1
- package/readme.md +59 -4
package/docs/getting-access.md
CHANGED
|
@@ -6,7 +6,8 @@ fetch the BankID cert. That needs two things, in this order:
|
|
|
6
6
|
1. **VPN** — the cluster API is not reachable from the open internet.
|
|
7
7
|
2. **kubectl access** — a client certificate identifying you to the cluster.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Both come from an admin**: they create your VPN tunnel and your cluster credential, and send you two
|
|
10
|
+
files — a WireGuard `.conf` and a `kubeconfig`. You install them and check they work. See
|
|
10
11
|
[issuing-access.md](./issuing-access.md) for their side.
|
|
11
12
|
|
|
12
13
|
---
|
|
@@ -14,98 +15,50 @@ The VPN part is self-service. The kubectl part needs an admin to sign your certi
|
|
|
14
15
|
## 0. Prerequisites
|
|
15
16
|
|
|
16
17
|
```bash
|
|
17
|
-
brew install wireguard-tools
|
|
18
|
+
brew install wireguard-tools kubernetes-cli
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
`openssl` ships with macOS. Check everything is there:
|
|
21
22
|
|
|
22
23
|
```bash
|
|
23
|
-
wg --version &&
|
|
24
|
+
wg --version && kubectl version --client && openssl version
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
---
|
|
27
28
|
|
|
28
29
|
## 1. VPN
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
**Your admin creates the tunnel and sends you a `.conf` file.** You do not create it yourself and you do
|
|
32
|
+
not need a Robo10 token for this — ask an admin, who follows
|
|
33
|
+
[issuing-access.md § 1](./issuing-access.md#1-vpn--create-and-hand-over-a-tunnel).
|
|
31
34
|
|
|
32
|
-
The
|
|
33
|
-
|
|
35
|
+
The file they send you contains a **private key**. Treat it like a password: keep it out of chat, tickets
|
|
36
|
+
and git, install it, and delete the copy they sent you.
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
TOKEN="$(go run github.com/strehle/cmdline-openid-client/openid-client@latest \
|
|
37
|
-
-issuer https://oidc-provider-backend.api.staging.roboten-dev.com \
|
|
38
|
-
-client_id ro_authentication_cl_id \
|
|
39
|
-
-port 34466 | awk '/===/ { if (start) nextfile; start = 1; next } start { print }' | jq -r .access_token)"
|
|
40
|
-
|
|
41
|
-
[ -n "$TOKEN" ] && echo "token acquired" || echo "no token — did the browser login complete?"
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Keep using **this same terminal** for the next steps: `$TOKEN` only exists in this shell.
|
|
45
|
-
|
|
46
|
-
<details>
|
|
47
|
-
<summary>Alternative if the Go client will not run</summary>
|
|
38
|
+
### 1a. Install the config
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
3. Copy the value of the `x-op-token` cookie.
|
|
52
|
-
4. In your terminal: `TOKEN=<the-cookie-value>`
|
|
53
|
-
</details>
|
|
54
|
-
|
|
55
|
-
### 1b. Create your tunnel
|
|
56
|
-
|
|
57
|
-
One tunnel per person per machine. Name it after yourself so admins can tell them apart:
|
|
40
|
+
`wg-quick` takes the interface name from the **filename**, so the name you pick here is the name you use in
|
|
41
|
+
every later command. Check first what is already there — **do not overwrite an existing tunnel**:
|
|
58
42
|
|
|
59
43
|
```bash
|
|
60
|
-
|
|
61
|
-
curl -sS -H "Authorization: Bearer $TOKEN" \
|
|
62
|
-
-H 'Content-Type: application/json' \
|
|
63
|
-
-d "{ \"name\": \"$NAME\" }" \
|
|
64
|
-
https://vpn.api.staging.roboten-dev.com/v1/vpn-tunnels
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
The response contains the tunnel's **id** — you need it next. To find it again later:
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
curl -sS -H "Authorization: Bearer $TOKEN" https://vpn.api.staging.roboten-dev.com/v1/vpn-tunnels
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
### 1c. Download the tunnel config
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
ID=<the id from the previous step>
|
|
77
|
-
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
|
|
78
|
-
https://vpn.api.staging.roboten-dev.com/v1/vpn-tunnels/$ID/actions/get-config \
|
|
79
|
-
> ~/roboten.conf
|
|
44
|
+
ls /opt/homebrew/etc/wireguard/
|
|
80
45
|
```
|
|
81
46
|
|
|
82
|
-
|
|
83
|
-
> chat or a ticket.
|
|
84
|
-
|
|
85
|
-
### 1d. Install it
|
|
86
|
-
|
|
87
|
-
`wg-quick` takes the interface name from the **filename**, so the name you choose here is the name you use
|
|
88
|
-
in every later command. Pick something that will not collide with a tunnel you already have:
|
|
47
|
+
Then install it (using `roboten` as the interface name):
|
|
89
48
|
|
|
90
49
|
```bash
|
|
91
50
|
sudo mkdir -p /opt/homebrew/etc/wireguard
|
|
92
|
-
sudo cp ~/
|
|
51
|
+
sudo cp ~/Downloads/<the-file-they-sent>.conf /opt/homebrew/etc/wireguard/roboten.conf
|
|
93
52
|
sudo chmod 600 /opt/homebrew/etc/wireguard/roboten.conf
|
|
94
|
-
rm ~/
|
|
53
|
+
rm ~/Downloads/<the-file-they-sent>.conf # the copy under /opt/homebrew is the one wg-quick reads
|
|
95
54
|
```
|
|
96
55
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
ls /opt/homebrew/etc/wireguard/
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
### 1e. Connect, disconnect, restart
|
|
56
|
+
### 1b. Connect, disconnect, restart
|
|
104
57
|
|
|
105
58
|
```bash
|
|
106
59
|
sudo wg-quick up roboten # connect
|
|
107
60
|
sudo wg-quick down roboten # disconnect
|
|
108
|
-
sudo wg-quick down roboten && sudo wg-quick up roboten # restart (after a config change or
|
|
61
|
+
sudo wg-quick down roboten && sudo wg-quick up roboten # restart (after a config change or sleep/wake)
|
|
109
62
|
|
|
110
63
|
sudo wg show # status: handshake time + transfer counters
|
|
111
64
|
```
|
|
@@ -122,75 +75,59 @@ sudo brew services start wireguard-tools # starts wg0 by default
|
|
|
122
75
|
|
|
123
76
|
That service is hard-wired to the `wg0` interface, so it only helps if you named your file `wg0.conf`.
|
|
124
77
|
|
|
125
|
-
###
|
|
78
|
+
### 1c. Verify the VPN works
|
|
126
79
|
|
|
127
80
|
```bash
|
|
128
81
|
sudo wg show # expect a recent "latest handshake"
|
|
129
82
|
nc -vz k8s.roboten-infra.com 64430 # expect: succeeded / open
|
|
130
83
|
```
|
|
131
84
|
|
|
132
|
-
If `nc` hangs or is refused, the tunnel is not carrying traffic — restart it (
|
|
85
|
+
If `nc` hangs or is refused, the tunnel is not carrying traffic — restart it (1b). Until this passes, no
|
|
133
86
|
amount of kubectl configuration will help.
|
|
134
87
|
|
|
88
|
+
> The tunnel routes the cluster network (including the API server) through WireGuard. If you already have
|
|
89
|
+
> another VPN claiming those ranges, they will fight over the routes — bring only one up at a time.
|
|
90
|
+
|
|
135
91
|
---
|
|
136
92
|
|
|
137
93
|
## 2. kubectl access
|
|
138
94
|
|
|
139
|
-
|
|
140
|
-
|
|
95
|
+
**Your admin sends you a ready-made `kubeconfig` file.** You do not generate keys or assemble anything —
|
|
96
|
+
they create the credential and hand it over
|
|
97
|
+
([issuing-access.md § 2](./issuing-access.md#2-kubectl--create-their-credential)).
|
|
141
98
|
|
|
142
|
-
|
|
99
|
+
Like the VPN config, that file contains a **private key**. Same handling: install it, then delete the copy
|
|
100
|
+
they sent you.
|
|
143
101
|
|
|
144
|
-
|
|
145
|
-
admin binds permissions to.
|
|
102
|
+
### 2a. Install it
|
|
146
103
|
|
|
147
|
-
|
|
148
|
-
USER_NAME=<firstname>-admin
|
|
149
|
-
mkdir -p ~/.kube/roboten && cd ~/.kube/roboten
|
|
104
|
+
**If you have no other clusters** — simplest, just put it in place:
|
|
150
105
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
106
|
+
```bash
|
|
107
|
+
mkdir -p ~/.kube
|
|
108
|
+
cp ~/Downloads/<yourname>.kubeconfig ~/.kube/config
|
|
109
|
+
chmod 600 ~/.kube/config
|
|
110
|
+
rm ~/Downloads/<yourname>.kubeconfig
|
|
154
111
|
```
|
|
155
112
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
Send **only** `<firstname>-admin.csr`. It is not secret — but the `.key` next to it is, and must never
|
|
159
|
-
leave your machine.
|
|
160
|
-
|
|
161
|
-
Ask them for three things back:
|
|
162
|
-
|
|
163
|
-
| | |
|
|
164
|
-
| --- | --- |
|
|
165
|
-
| `<firstname>-admin.crt` | your signed certificate |
|
|
166
|
-
| `ca.crt` | the cluster CA certificate |
|
|
167
|
-
| the API server URL | currently `https://k8s.roboten-infra.com:64430` |
|
|
168
|
-
|
|
169
|
-
### 2c. Assemble your kubeconfig
|
|
170
|
-
|
|
171
|
-
Put the two `.crt` files next to your key in `~/.kube/roboten`, then:
|
|
113
|
+
**If you already use kubectl for something else**, merge instead of overwriting:
|
|
172
114
|
|
|
173
115
|
```bash
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
KUBECONFIG=~/.kube/config kubectl config set-credentials "$USER_NAME" \
|
|
182
|
-
--client-certificate="$USER_NAME.crt" --client-key="$USER_NAME.key" --embed-certs=true
|
|
116
|
+
cp ~/.kube/config ~/.kube/config.backup # always back up first
|
|
117
|
+
KUBECONFIG=~/.kube/config:~/Downloads/<yourname>.kubeconfig \
|
|
118
|
+
kubectl config view --flatten > /tmp/merged && mv /tmp/merged ~/.kube/config
|
|
119
|
+
chmod 600 ~/.kube/config
|
|
120
|
+
rm ~/Downloads/<yourname>.kubeconfig
|
|
121
|
+
```
|
|
183
122
|
|
|
184
|
-
|
|
185
|
-
--cluster=kubernetes --user="$USER_NAME"
|
|
123
|
+
Then select the context (the admin will tell you its name, `<firstname>-admin@kubernetes`):
|
|
186
124
|
|
|
187
|
-
|
|
125
|
+
```bash
|
|
126
|
+
kubectl config get-contexts
|
|
127
|
+
kubectl config use-context <firstname>-admin@kubernetes
|
|
188
128
|
```
|
|
189
129
|
|
|
190
|
-
|
|
191
|
-
only a backup after this point. Keep the `.key` anyway — you need it if you ever rebuild the config.
|
|
192
|
-
|
|
193
|
-
### 2d. Verify
|
|
130
|
+
### 2b. Verify
|
|
194
131
|
|
|
195
132
|
With the VPN up:
|
|
196
133
|
|
|
@@ -228,12 +165,11 @@ npx @dooer/dooer-test-env@latest setup
|
|
|
228
165
|
|
|
229
166
|
| Symptom | Cause |
|
|
230
167
|
| --- | --- |
|
|
231
|
-
| `dial tcp … i/o timeout` from kubectl | VPN is down — `sudo wg show`, then restart it (
|
|
168
|
+
| `dial tcp … i/o timeout` from kubectl | VPN is down — `sudo wg show`, then restart it (1b) |
|
|
232
169
|
| `nc` to the API server hangs | Tunnel is up but not carrying traffic; restart it |
|
|
233
|
-
| `error: You must be logged in to the server (Unauthorized)` |
|
|
234
|
-
| `Error from server (Forbidden)` | You *are* authenticated; your user has no permissions bound yet — ask the admin to complete their
|
|
235
|
-
| `no token — did the browser login complete?` | The OIDC flow did not finish; make sure you used your `@robo10.com` account |
|
|
170
|
+
| `error: You must be logged in to the server (Unauthorized)` | The certificate in your kubeconfig is expired or unknown to the cluster — ask for a re-issue |
|
|
171
|
+
| `Error from server (Forbidden)` | You *are* authenticated; your user has no permissions bound yet — ask the admin to complete their § 2e (bind permissions) |
|
|
236
172
|
| `wg-quick: 'roboten' already exists` | The interface is already up — `sudo wg-quick down roboten` first |
|
|
237
173
|
|
|
238
|
-
Certificates are issued for **one year**. When yours expires
|
|
239
|
-
|
|
174
|
+
Certificates are issued for **one year**. When yours expires, ask your admin for a new kubeconfig and
|
|
175
|
+
repeat section 2 — your permissions stay in place, so they only have to re-issue the credential.
|
package/docs/issuing-access.md
CHANGED
|
@@ -4,66 +4,133 @@ The other side of [getting-access.md](./getting-access.md). Two independent thin
|
|
|
4
4
|
|
|
5
5
|
| | Who does it | What you do |
|
|
6
6
|
| --- | --- | --- |
|
|
7
|
-
| **VPN tunnel** |
|
|
8
|
-
| **kubectl credential** |
|
|
7
|
+
| **VPN tunnel** | you | create it and send them the config |
|
|
8
|
+
| **kubectl credential** | you | create the certificate, bind permissions, send a kubeconfig |
|
|
9
9
|
|
|
10
|
-
You need `kubectl` with cluster-admin (`system:masters`) and
|
|
10
|
+
You need a Robo10 token (§1a), `kubectl` with cluster-admin (`system:masters`), and your own VPN up.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
## 1. VPN —
|
|
14
|
+
## 1. VPN — create and hand over a tunnel
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
You create the tunnel and send the developer its config. They do not need a Robo10 token, which matters:
|
|
17
|
+
a brand-new joiner often has no working login yet, and the VPN API generates the keypair **server-side**
|
|
18
|
+
anyway — the config comes back with the private key in it either way, so there is no custody advantage to
|
|
19
|
+
making them do it.
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
### 1a. Get a Robo10 token (yours)
|
|
22
|
+
|
|
23
|
+
Opens a browser — log in with **your `@robo10.com` account**:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
TOKEN="$(go run github.com/strehle/cmdline-openid-client/openid-client@latest \
|
|
27
|
+
-issuer https://oidc-provider-backend.api.staging.roboten-dev.com \
|
|
28
|
+
-client_id ro_authentication_cl_id \
|
|
29
|
+
-port 34466 | awk '/===/ { if (start) nextfile; start = 1; next } start { print }' | jq -r .access_token)"
|
|
30
|
+
|
|
31
|
+
[ -n "$TOKEN" ] && echo "token acquired" || echo "no token — did the browser login complete?"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Needs `go` and `jq` (`brew install go jq`). Keep using **this same terminal**: `$TOKEN` lives only in this
|
|
35
|
+
shell, and everything below needs it.
|
|
36
|
+
|
|
37
|
+
<details>
|
|
38
|
+
<summary>Alternative if the Go client will not run</summary>
|
|
39
|
+
|
|
40
|
+
1. Open <https://chat.roboten.com> and log in.
|
|
41
|
+
2. DevTools → **Application** → **Storage → Cookies**.
|
|
42
|
+
3. Copy the value of the `x-op-token` cookie.
|
|
43
|
+
4. In your terminal: `TOKEN=<the-cookie-value>`
|
|
44
|
+
</details>
|
|
45
|
+
|
|
46
|
+
### 1b. Create their tunnel
|
|
47
|
+
|
|
48
|
+
Name it after the person (and their machine if they need more than one) so the list stays readable:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
NAME="Firstname Lastname"
|
|
52
|
+
curl -sS -H "Authorization: Bearer $TOKEN" \
|
|
53
|
+
-H 'Content-Type: application/json' \
|
|
54
|
+
-d "{ \"name\": \"$NAME\" }" \
|
|
55
|
+
https://vpn.api.staging.roboten-dev.com/v1/vpn-tunnels
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The response contains the tunnel's **id**. To find it again later, list them (1d).
|
|
59
|
+
|
|
60
|
+
### 1c. Download the config and hand it over
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
ID=<the id from the previous step>
|
|
64
|
+
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
|
|
65
|
+
https://vpn.api.staging.roboten-dev.com/v1/vpn-tunnels/$ID/actions/get-config \
|
|
66
|
+
> ~/Downloads/$NAME.conf
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
> **This file contains a private key.** Send it over a channel you would send a password over — a shared
|
|
70
|
+
> vault entry or an expiring secure link — not email, Slack or a ticket. Tell them to install it and delete
|
|
71
|
+
> the copy you sent, and delete your own copy once they confirm.
|
|
22
72
|
|
|
23
73
|
Then point them at [getting-access.md § 1](./getting-access.md#1-vpn).
|
|
24
74
|
|
|
25
|
-
|
|
26
|
-
tunnel
|
|
75
|
+
One tunnel per person per machine: two devices sharing one config will fight over the session, so create a
|
|
76
|
+
second tunnel rather than re-sending the first.
|
|
27
77
|
|
|
28
|
-
|
|
78
|
+
### 1d. List tunnels
|
|
29
79
|
|
|
30
80
|
```bash
|
|
31
81
|
curl -sS -H "Authorization: Bearer $TOKEN" https://vpn.api.staging.roboten-dev.com/v1/vpn-tunnels
|
|
32
82
|
```
|
|
33
83
|
|
|
34
|
-
|
|
35
|
-
|
|
84
|
+
### 1e. Revoke a tunnel
|
|
85
|
+
|
|
86
|
+
`DELETE` is not in the VPN service's own docs, but it works (verified 2026-09-04 — returns `204` and the
|
|
87
|
+
tunnel disappears from the list):
|
|
36
88
|
|
|
37
89
|
```bash
|
|
38
90
|
curl -sS -X DELETE -H "Authorization: Bearer $TOKEN" \
|
|
39
91
|
https://vpn.api.staging.roboten-dev.com/v1/vpn-tunnels/$ID
|
|
40
92
|
```
|
|
41
93
|
|
|
42
|
-
That invalidates the tunnel
|
|
43
|
-
|
|
94
|
+
That invalidates the tunnel server-side, so it is the real off-switch for VPN access when someone leaves —
|
|
95
|
+
do it *and* revoke their kubectl permissions (§3).
|
|
44
96
|
|
|
45
97
|
---
|
|
46
98
|
|
|
47
|
-
## 2. kubectl —
|
|
99
|
+
## 2. kubectl — create their credential
|
|
48
100
|
|
|
49
101
|
The cluster authenticates users with **x509 client certificates** signed by the cluster CA. The username is
|
|
50
102
|
the certificate's **Common Name (CN)**; RBAC is bound to that string.
|
|
51
103
|
|
|
52
|
-
You
|
|
104
|
+
You do all of it and hand over a finished `kubeconfig`.
|
|
53
105
|
|
|
54
|
-
|
|
106
|
+
> Everything below was verified end to end against the live cluster (2026-09-04) with a throwaway
|
|
107
|
+
> `guidetest-admin`: the resulting kubeconfig read `dooer-production` and `dooer-staging`, including
|
|
108
|
+
> secrets. The test user was then removed.
|
|
55
109
|
|
|
56
110
|
```bash
|
|
57
|
-
USER_NAME=<firstname>-admin
|
|
58
|
-
|
|
111
|
+
USER_NAME=<firstname>-admin # becomes their cluster username
|
|
112
|
+
WORKDIR=$(mktemp -d) && cd "$WORKDIR"
|
|
113
|
+
```
|
|
59
114
|
|
|
115
|
+
Work in a temp directory — it holds their private key until you hand it over, and you delete it at the end.
|
|
116
|
+
|
|
117
|
+
### 2a. Generate their key and CSR
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
openssl genrsa -out "$USER_NAME.key" 2048
|
|
121
|
+
openssl req -new -key "$USER_NAME.key" -out "$USER_NAME.csr" -subj "/CN=$USER_NAME"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 2b. Submit it as a CertificateSigningRequest
|
|
125
|
+
|
|
126
|
+
```bash
|
|
60
127
|
cat <<EOF | kubectl apply -f -
|
|
61
128
|
apiVersion: certificates.k8s.io/v1
|
|
62
129
|
kind: CertificateSigningRequest
|
|
63
130
|
metadata:
|
|
64
131
|
name: $USER_NAME
|
|
65
132
|
spec:
|
|
66
|
-
request: $(base64 -i "$
|
|
133
|
+
request: $(base64 -i "$USER_NAME.csr" | tr -d '\n')
|
|
67
134
|
signerName: kubernetes.io/kube-apiserver-client
|
|
68
135
|
expirationSeconds: 31536000 # 1 year
|
|
69
136
|
usages:
|
|
@@ -71,15 +138,10 @@ spec:
|
|
|
71
138
|
EOF
|
|
72
139
|
```
|
|
73
140
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
openssl req -in "$CSR_FILE" -noout -subject
|
|
79
|
-
# subject=CN=<firstname>-admin
|
|
80
|
-
```
|
|
141
|
+
If the name already exists (a renewal), delete the old object first:
|
|
142
|
+
`kubectl delete csr "$USER_NAME" --ignore-not-found`.
|
|
81
143
|
|
|
82
|
-
###
|
|
144
|
+
### 2c. Approve and extract the certificate
|
|
83
145
|
|
|
84
146
|
```bash
|
|
85
147
|
kubectl certificate approve "$USER_NAME"
|
|
@@ -90,18 +152,16 @@ openssl x509 -in "$USER_NAME.crt" -noout -subject -issuer -dates
|
|
|
90
152
|
|
|
91
153
|
Expect `subject=CN=<firstname>-admin`, `issuer=CN=kubernetes`, and a one-year window.
|
|
92
154
|
|
|
93
|
-
If `.status.certificate` is empty
|
|
94
|
-
stays empty,
|
|
95
|
-
|
|
96
|
-
### 2c. Extract the cluster CA
|
|
155
|
+
If `.status.certificate` is empty the signer has not run yet — wait a couple of seconds and repeat. If it
|
|
156
|
+
stays empty, check `kubectl describe csr "$USER_NAME"`.
|
|
97
157
|
|
|
98
|
-
|
|
158
|
+
### 2d. Extract the cluster CA
|
|
99
159
|
|
|
100
160
|
```bash
|
|
101
161
|
kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt
|
|
102
162
|
```
|
|
103
163
|
|
|
104
|
-
###
|
|
164
|
+
### 2e. Bind permissions
|
|
105
165
|
|
|
106
166
|
This is what makes the certificate useful — without it they authenticate but can do nothing. These are the
|
|
107
167
|
same five ClusterRoles the existing admins (`jimmy-admin`, `sam-admin`, `trajko-admin`) hold:
|
|
@@ -125,25 +185,54 @@ The declarative equivalent lives in
|
|
|
125
185
|
is enough for the cluster, but that file is the record of who has access — **add the new user there and
|
|
126
186
|
commit it**, or the next person reading it will get a false picture.
|
|
127
187
|
|
|
128
|
-
|
|
188
|
+
On a renewal, skip this step: the bindings are attached to the username, not to the certificate.
|
|
189
|
+
|
|
190
|
+
### 2f. Assemble the kubeconfig
|
|
129
191
|
|
|
130
|
-
|
|
192
|
+
`--embed-certs=true` inlines the key and certificates, so the file you hand over is self-contained:
|
|
131
193
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
194
|
+
```bash
|
|
195
|
+
SERVER=https://k8s.roboten-infra.com:64430
|
|
196
|
+
KC="$WORKDIR/$USER_NAME.kubeconfig"
|
|
197
|
+
|
|
198
|
+
KUBECONFIG="$KC" kubectl config set-cluster kubernetes \
|
|
199
|
+
--server="$SERVER" --certificate-authority=ca.crt --embed-certs=true
|
|
135
200
|
|
|
136
|
-
|
|
201
|
+
KUBECONFIG="$KC" kubectl config set-credentials "$USER_NAME" \
|
|
202
|
+
--client-certificate="$USER_NAME.crt" --client-key="$USER_NAME.key" --embed-certs=true
|
|
137
203
|
|
|
138
|
-
|
|
204
|
+
KUBECONFIG="$KC" kubectl config set-context "$USER_NAME@kubernetes" \
|
|
205
|
+
--cluster=kubernetes --user="$USER_NAME"
|
|
206
|
+
|
|
207
|
+
KUBECONFIG="$KC" kubectl config use-context "$USER_NAME@kubernetes"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 2g. Verify it before handing it over
|
|
211
|
+
|
|
212
|
+
Test the actual file, not just the RBAC — with your own VPN up:
|
|
139
213
|
|
|
140
214
|
```bash
|
|
141
|
-
kubectl
|
|
142
|
-
kubectl
|
|
143
|
-
kubectl
|
|
215
|
+
KUBECONFIG="$KC" kubectl auth whoami # Username: <firstname>-admin
|
|
216
|
+
KUBECONFIG="$KC" kubectl get pods -n dooer-staging | head -3
|
|
217
|
+
KUBECONFIG="$KC" kubectl get pods -n dooer-production | head -3
|
|
218
|
+
KUBECONFIG="$KC" kubectl auth can-i get secrets -n dooer-staging # yes
|
|
144
219
|
```
|
|
145
220
|
|
|
146
|
-
|
|
221
|
+
If `whoami` works but the reads say `Forbidden`, step 2e did not take.
|
|
222
|
+
|
|
223
|
+
### 2h. Hand it over, then clean up
|
|
224
|
+
|
|
225
|
+
> **The kubeconfig contains their private key.** Send it the same way as the VPN config — a shared vault
|
|
226
|
+
> entry or an expiring secure link, never email, Slack or a ticket.
|
|
227
|
+
|
|
228
|
+
Tell them the context name (`<firstname>-admin@kubernetes`) and point them at
|
|
229
|
+
[getting-access.md § 2](./getting-access.md#2-kubectl-access).
|
|
230
|
+
|
|
231
|
+
Once they confirm it works, destroy your copy — you are holding their key until you do:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
rm -rf "$WORKDIR"
|
|
235
|
+
```
|
|
147
236
|
|
|
148
237
|
---
|
|
149
238
|
|
|
@@ -169,9 +258,11 @@ Also remove them from `clusterrole-admin.yaml` and commit.
|
|
|
169
258
|
|
|
170
259
|
## Renewals
|
|
171
260
|
|
|
172
|
-
A certificate expiring is routine
|
|
173
|
-
|
|
174
|
-
|
|
261
|
+
A certificate expiring is routine. Repeat **§2a–2d and 2f–2h** and send them a fresh kubeconfig; they
|
|
262
|
+
re-run [getting-access.md § 2](./getting-access.md#2-kubectl-access).
|
|
263
|
+
|
|
264
|
+
Skip **2e** — ClusterRoleBindings are bound to the username, not to the certificate, so they survive a
|
|
265
|
+
re-issue untouched.
|
|
175
266
|
|
|
176
267
|
Delete the old CSR object first, or the new one collides on the name:
|
|
177
268
|
|
package/lib/cli.js
CHANGED
|
@@ -15,6 +15,8 @@ module.exports = async function cli() {
|
|
|
15
15
|
.command(require('./command/service')) // service start|stop|restart|deploy|local|unlocal|version
|
|
16
16
|
.command(require('./command/db')) // db build|pull|snapshot|rollback
|
|
17
17
|
.command(require('./command/customer')) // customer copy|purge
|
|
18
|
+
.command(require('./command/user')) // user search|get|create|partners
|
|
19
|
+
.command(require('./command/partner')) // partner list|users|assign|unassign
|
|
18
20
|
.command(require('./command/shred')) // shred (localhost only)
|
|
19
21
|
.command(require('./command/bankid')) // bankid pull|status|clear (staging certs → keychain)
|
|
20
22
|
.command(require('./command/validation')) // validation on|off|status (output-schema checks)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const u = require('../users')
|
|
3
|
+
const { targetOptions, writeOptions, label, printTable } = require('./target')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
command: 'partner <command>',
|
|
7
|
+
describe: 'list partners and manage their user membership (local env by default)',
|
|
8
|
+
builder: (y) =>
|
|
9
|
+
y
|
|
10
|
+
.command({
|
|
11
|
+
command: 'list',
|
|
12
|
+
describe: 'list every partner in the environment',
|
|
13
|
+
builder: targetOptions,
|
|
14
|
+
handler: async (argv) => {
|
|
15
|
+
const ns = u.targetOf(argv)
|
|
16
|
+
const rows = await u.withDb(ns, (db) => u.listPartners(db))
|
|
17
|
+
console.log(chalk.bold(`\n${rows.length} partner(s) in ${label(ns)}\n`))
|
|
18
|
+
printTable(rows, [
|
|
19
|
+
['NAME', (r) => r.name],
|
|
20
|
+
['DOMAIN', (r) => r.domain],
|
|
21
|
+
['USERS', (r) => r.users],
|
|
22
|
+
['STATUS', (r) => r.status],
|
|
23
|
+
['DEFAULT', (r) => (r.isDefaultPartner ? 'yes' : '')],
|
|
24
|
+
['ID', (r) => r.id],
|
|
25
|
+
])
|
|
26
|
+
console.log()
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
.command({
|
|
30
|
+
command: 'users <partner>',
|
|
31
|
+
describe: 'list the users of one partner and their level',
|
|
32
|
+
builder: (y2) =>
|
|
33
|
+
targetOptions(y2).positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
|
|
34
|
+
handler: async (argv) => {
|
|
35
|
+
const ns = u.targetOf(argv)
|
|
36
|
+
const { partner, rows } = await u.withDb(ns, async (db) => {
|
|
37
|
+
const partner = await u.findPartner(db, argv.partner)
|
|
38
|
+
const { rows } = await db.query(
|
|
39
|
+
`SELECT us.users_pk AS id, us.email, us.first_name, us.last_name,
|
|
40
|
+
pu.role AS level, pu.status, pu."defaultRepresentative"
|
|
41
|
+
FROM "service_accounts"."partnerUser" pu
|
|
42
|
+
JOIN "service_accounts".users us ON us.users_pk = pu."userId"
|
|
43
|
+
WHERE pu."partnerId" = $1 ORDER BY us.email`,
|
|
44
|
+
[partner.id]
|
|
45
|
+
)
|
|
46
|
+
return { partner, rows }
|
|
47
|
+
})
|
|
48
|
+
console.log(chalk.bold(`\n${partner.name} — ${rows.length} user(s)`) + chalk.gray(` (${label(ns)})\n`))
|
|
49
|
+
printTable(rows, [
|
|
50
|
+
['EMAIL', (r) => r.email],
|
|
51
|
+
['NAME', (r) => `${r.first_name} ${r.last_name}`],
|
|
52
|
+
['LEVEL', (r) => r.level],
|
|
53
|
+
['STATUS', (r) => r.status],
|
|
54
|
+
['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
|
|
55
|
+
['ID', (r) => r.id],
|
|
56
|
+
])
|
|
57
|
+
console.log()
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
.command({
|
|
61
|
+
command: 'assign <user> <partner>',
|
|
62
|
+
describe: 'add a user to a partner (or change their level). Dry-run by default.',
|
|
63
|
+
builder: (y2) =>
|
|
64
|
+
writeOptions(y2)
|
|
65
|
+
.positional('user', { type: 'string', describe: 'user id or email' })
|
|
66
|
+
.positional('partner', { type: 'string', describe: 'partner id, name or domain' })
|
|
67
|
+
.option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level within the partner' }),
|
|
68
|
+
handler: async (argv) => {
|
|
69
|
+
const ns = u.targetOf(argv)
|
|
70
|
+
u.guardWrite(ns, argv)
|
|
71
|
+
const { user, partner, res } = await u.withDb(ns, async (db) => {
|
|
72
|
+
const user = await u.findUser(db, argv.user)
|
|
73
|
+
const partner = await u.findPartner(db, argv.partner)
|
|
74
|
+
const res = await u.assignPartner(db, { user, partner, level: argv.level, execute: argv.execute })
|
|
75
|
+
return { user, partner, res }
|
|
76
|
+
})
|
|
77
|
+
const what =
|
|
78
|
+
res.previous && res.previous !== argv.level
|
|
79
|
+
? `level ${res.previous} → ${argv.level}`
|
|
80
|
+
: res.previous
|
|
81
|
+
? `already a ${argv.level} (no change)`
|
|
82
|
+
: `added as ${argv.level}`
|
|
83
|
+
console.log(
|
|
84
|
+
(argv.execute ? chalk.green('\n' + what) : chalk.yellow(`\nDRY RUN — would be ${what}`)) +
|
|
85
|
+
`: ${user.email} @ ${partner.name}` +
|
|
86
|
+
chalk.gray(` (${label(ns)})`)
|
|
87
|
+
)
|
|
88
|
+
if (!argv.execute) console.log(chalk.gray(' Re-run with --execute to apply.'))
|
|
89
|
+
console.log()
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
.command({
|
|
93
|
+
command: 'unassign <user> <partner>',
|
|
94
|
+
describe: 'remove a user from a partner. Dry-run by default.',
|
|
95
|
+
builder: (y2) =>
|
|
96
|
+
writeOptions(y2)
|
|
97
|
+
.positional('user', { type: 'string', describe: 'user id or email' })
|
|
98
|
+
.positional('partner', { type: 'string', describe: 'partner id, name or domain' }),
|
|
99
|
+
handler: async (argv) => {
|
|
100
|
+
const ns = u.targetOf(argv)
|
|
101
|
+
u.guardWrite(ns, argv)
|
|
102
|
+
const { user, partner, res } = await u.withDb(ns, async (db) => {
|
|
103
|
+
const user = await u.findUser(db, argv.user)
|
|
104
|
+
const partner = await u.findPartner(db, argv.partner)
|
|
105
|
+
const res = await u.unassignPartner(db, { user, partner, execute: argv.execute })
|
|
106
|
+
return { user, partner, res }
|
|
107
|
+
})
|
|
108
|
+
console.log(
|
|
109
|
+
(argv.execute ? chalk.green('\nremoved') : chalk.yellow('\nDRY RUN — would remove')) +
|
|
110
|
+
` ${user.email} (${res.level}) from ${partner.name}` +
|
|
111
|
+
chalk.gray(` (${label(ns)})`)
|
|
112
|
+
)
|
|
113
|
+
if (!argv.execute) console.log(chalk.gray(' Re-run with --execute to apply.'))
|
|
114
|
+
console.log()
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
.demandCommand(1, 'Use: partner list | partner users | partner assign | partner unassign')
|
|
118
|
+
.strict(),
|
|
119
|
+
handler: () => {},
|
|
120
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
|
|
3
|
+
// Shared bits for the environment-addressed commands (user / partner). The LOCAL env is the default —
|
|
4
|
+
// these are day-to-day local-dev commands — and a k8s namespace is the deliberate opt-in.
|
|
5
|
+
const targetOptions = (y) =>
|
|
6
|
+
y
|
|
7
|
+
.option('target-local', { type: 'boolean', describe: 'act on the LOCAL env (default)' })
|
|
8
|
+
.option('target-namespace', { type: 'string', describe: 'act on a k8s namespace instead, e.g. dooer-staging' })
|
|
9
|
+
|
|
10
|
+
const writeOptions = (y) =>
|
|
11
|
+
targetOptions(y)
|
|
12
|
+
.option('confirm-production', { type: 'boolean', describe: 'REQUIRED to write to dooer-production' })
|
|
13
|
+
.option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
|
|
14
|
+
|
|
15
|
+
const label = (ns) => (ns === 'local' ? 'local env' : `namespace ${ns}`)
|
|
16
|
+
|
|
17
|
+
// Minimal column-aligned printer: `columns` is [[header, rowFn], …]. Values are stringified, null shown
|
|
18
|
+
// as a dim dash so an empty column still lines up.
|
|
19
|
+
function printTable(rows, columns) {
|
|
20
|
+
if (!rows.length) {
|
|
21
|
+
console.log(chalk.gray(' (none)'))
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const cell = (v) => (v === null || v === undefined || v === '' ? '-' : String(v))
|
|
25
|
+
const body = rows.map((r) => columns.map(([, fn]) => cell(fn(r))))
|
|
26
|
+
const widths = columns.map(([h], i) => Math.max(h.length, ...body.map((b) => b[i].length)))
|
|
27
|
+
console.log(' ' + columns.map(([h], i) => chalk.bold(h.padEnd(widths[i]))).join(' '))
|
|
28
|
+
for (const b of body) console.log(' ' + b.map((v, i) => v.padEnd(widths[i])).join(' '))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { targetOptions, writeOptions, label, printTable }
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const chalk = require('chalk')
|
|
2
|
+
const u = require('../users')
|
|
3
|
+
const { targetOptions, writeOptions, label, printTable } = require('./target')
|
|
4
|
+
|
|
5
|
+
const date = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null)
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
command: 'user <command>',
|
|
9
|
+
describe: 'find, inspect and create users (local env by default)',
|
|
10
|
+
builder: (y) =>
|
|
11
|
+
y
|
|
12
|
+
.command({
|
|
13
|
+
command: 'search [query]',
|
|
14
|
+
describe: 'find users by email, name, personnummer or id (no query = the most recent users)',
|
|
15
|
+
builder: (y2) =>
|
|
16
|
+
targetOptions(y2)
|
|
17
|
+
.positional('query', { type: 'string', describe: 'substring to match' })
|
|
18
|
+
.option('role', { choices: u.USER_ROLES, describe: 'only users of this role' })
|
|
19
|
+
.option('limit', { type: 'number', default: 20, describe: 'max rows' }),
|
|
20
|
+
handler: async (argv) => {
|
|
21
|
+
const ns = u.targetOf(argv)
|
|
22
|
+
const rows = await u.withDb(ns, (db) =>
|
|
23
|
+
u.searchUsers(db, { query: argv.query, role: argv.role, limit: argv.limit })
|
|
24
|
+
)
|
|
25
|
+
console.log(
|
|
26
|
+
chalk.bold(`\n${rows.length} user(s) in ${label(ns)}`) +
|
|
27
|
+
(argv.query ? chalk.gray(` matching "${argv.query}"`) : '') +
|
|
28
|
+
'\n'
|
|
29
|
+
)
|
|
30
|
+
printTable(rows, [
|
|
31
|
+
['ID', (r) => r.id],
|
|
32
|
+
['EMAIL', (r) => r.email],
|
|
33
|
+
['NAME', (r) => `${r.first_name} ${r.last_name}`],
|
|
34
|
+
['ROLE', (r) => r.role],
|
|
35
|
+
['CREATED', (r) => date(r.inserted_at)],
|
|
36
|
+
['STATUS', (r) => (r.inactivated_at ? `inactivated ${date(r.inactivated_at)}` : 'active')],
|
|
37
|
+
])
|
|
38
|
+
console.log()
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
.command({
|
|
42
|
+
command: 'get <user>',
|
|
43
|
+
describe: 'show one user (by id or email) and the partners they belong to',
|
|
44
|
+
builder: (y2) => targetOptions(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
45
|
+
handler: async (argv) => {
|
|
46
|
+
const ns = u.targetOf(argv)
|
|
47
|
+
const { user, partners } = await u.withDb(ns, async (db) => {
|
|
48
|
+
const user = await u.findUser(db, argv.user)
|
|
49
|
+
return { user, partners: await u.partnersForUser(db, user.id) }
|
|
50
|
+
})
|
|
51
|
+
console.log(chalk.bold(`\n${user.first_name} ${user.last_name}`) + chalk.gray(` (${label(ns)})\n`))
|
|
52
|
+
const line = (k, v) => console.log(` ${k.padEnd(14)}${v === null || v === undefined ? '-' : v}`)
|
|
53
|
+
line('id', user.id)
|
|
54
|
+
line(
|
|
55
|
+
'email',
|
|
56
|
+
`${user.email}${user.email_verified ? chalk.gray(' (verified)') : chalk.yellow(' (unverified)')}`
|
|
57
|
+
)
|
|
58
|
+
line('role', user.role)
|
|
59
|
+
line('personnummer', user.personnummer)
|
|
60
|
+
line('password', user.has_password ? 'set' : chalk.yellow('none — cannot log in with a password'))
|
|
61
|
+
line('language', user.language)
|
|
62
|
+
line('created', date(user.inserted_at))
|
|
63
|
+
line('status', user.inactivated_at ? chalk.yellow(`inactivated ${date(user.inactivated_at)}`) : 'active')
|
|
64
|
+
if (user.internal) line('internal', 'yes')
|
|
65
|
+
console.log(chalk.bold(`\n partners (${partners.length})`))
|
|
66
|
+
printTable(partners, [
|
|
67
|
+
['PARTNER', (r) => r.name],
|
|
68
|
+
['LEVEL', (r) => r.level],
|
|
69
|
+
['STATUS', (r) => r.status],
|
|
70
|
+
['LAST LOGIN', (r) => date(r.lastLoginAt)],
|
|
71
|
+
['ID', (r) => r.id],
|
|
72
|
+
])
|
|
73
|
+
console.log()
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
.command({
|
|
77
|
+
command: 'create',
|
|
78
|
+
describe: 'create a user (password generated unless given). Dry-run by default.',
|
|
79
|
+
builder: (y2) =>
|
|
80
|
+
writeOptions(y2)
|
|
81
|
+
.option('email', { type: 'string', demandOption: true, describe: 'email address (the login)' })
|
|
82
|
+
.option('password', { type: 'string', describe: 'password to set (default: generated, printed once)' })
|
|
83
|
+
.option('role', { choices: u.USER_ROLES, default: 'hi', describe: 'user role at dooer' })
|
|
84
|
+
.option('personnummer', { type: 'string', describe: 'Swedish personnummer, 10 or 12 digits' })
|
|
85
|
+
.option('first-name', { type: 'string', describe: 'default: derived from the email' })
|
|
86
|
+
.option('last-name', { type: 'string', describe: 'default: derived from the email' })
|
|
87
|
+
.option('partner', { type: 'string', describe: 'also add to this partner (id, name or domain)' })
|
|
88
|
+
.option('level', { choices: u.PARTNER_LEVELS, default: 'member', describe: 'level in --partner' }),
|
|
89
|
+
handler: async (argv) => {
|
|
90
|
+
const ns = u.targetOf(argv)
|
|
91
|
+
u.guardWrite(ns, argv)
|
|
92
|
+
const derived = u.namesFromEmail(argv.email)
|
|
93
|
+
const firstName = argv.firstName || derived.firstName
|
|
94
|
+
const lastName = argv.lastName || derived.lastName
|
|
95
|
+
const password = argv.password || u.generatePassword()
|
|
96
|
+
const generated = !argv.password
|
|
97
|
+
const personnummer = argv.personnummer ? u.normalizePersonnummer(argv.personnummer) : null
|
|
98
|
+
|
|
99
|
+
const res = await u.withDb(ns, async (db) => {
|
|
100
|
+
// Resolve the partner BEFORE creating anything, so a typo fails without leaving a half-made user.
|
|
101
|
+
const partner = argv.partner ? await u.findPartner(db, argv.partner) : null
|
|
102
|
+
if (partner && argv.role !== 'hi') {
|
|
103
|
+
throw new Error(`--partner needs a "hi" user; role "${argv.role}" cannot belong to a partner`)
|
|
104
|
+
}
|
|
105
|
+
const created = await u.createUser(db, {
|
|
106
|
+
email: argv.email,
|
|
107
|
+
password,
|
|
108
|
+
role: argv.role,
|
|
109
|
+
personnummer,
|
|
110
|
+
firstName,
|
|
111
|
+
lastName,
|
|
112
|
+
execute: argv.execute,
|
|
113
|
+
})
|
|
114
|
+
// Only on --execute: a dry-run has no user id to attach the membership to, and the partner
|
|
115
|
+
// itself (plus the hi-only rule) has already been validated above.
|
|
116
|
+
if (partner && argv.execute) {
|
|
117
|
+
await u.assignPartner(db, {
|
|
118
|
+
user: { id: created.id, email: argv.email, role: argv.role },
|
|
119
|
+
partner,
|
|
120
|
+
level: argv.level,
|
|
121
|
+
execute: argv.execute,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
return { created, partner }
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
console.log(
|
|
128
|
+
(argv.execute ? chalk.green('\ncreated user') : chalk.yellow('\nDRY RUN — nothing written')) +
|
|
129
|
+
chalk.gray(` (${label(ns)})\n`)
|
|
130
|
+
)
|
|
131
|
+
const line = (k, v) => console.log(` ${k.padEnd(14)}${v}`)
|
|
132
|
+
if (argv.execute) line('id', res.created.id)
|
|
133
|
+
line('email', argv.email)
|
|
134
|
+
line('name', `${firstName} ${lastName}`)
|
|
135
|
+
line('role', argv.role)
|
|
136
|
+
if (personnummer) line('personnummer', personnummer)
|
|
137
|
+
line('password', chalk.bold(password) + (generated ? chalk.gray(' (generated)') : ''))
|
|
138
|
+
if (res.partner) line('partner', `${res.partner.name} — level ${argv.level}`)
|
|
139
|
+
if (generated && argv.execute) {
|
|
140
|
+
console.log(chalk.gray('\n The password is stored as a bcrypt hash — this is the only time it is shown.'))
|
|
141
|
+
}
|
|
142
|
+
if (!argv.execute) console.log(chalk.gray('\n Re-run with --execute to create it.'))
|
|
143
|
+
console.log()
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
.command({
|
|
147
|
+
command: 'partners <user>',
|
|
148
|
+
describe: 'list the partners a user belongs to, and at what level',
|
|
149
|
+
builder: (y2) => targetOptions(y2).positional('user', { type: 'string', describe: 'user id or email' }),
|
|
150
|
+
handler: async (argv) => {
|
|
151
|
+
const ns = u.targetOf(argv)
|
|
152
|
+
const { user, partners } = await u.withDb(ns, async (db) => {
|
|
153
|
+
const user = await u.findUser(db, argv.user)
|
|
154
|
+
return { user, partners: await u.partnersForUser(db, user.id) }
|
|
155
|
+
})
|
|
156
|
+
console.log(
|
|
157
|
+
chalk.bold(`\n${user.email} belongs to ${partners.length} partner(s)`) + chalk.gray(` (${label(ns)})\n`)
|
|
158
|
+
)
|
|
159
|
+
printTable(partners, [
|
|
160
|
+
['PARTNER', (r) => r.name],
|
|
161
|
+
['LEVEL', (r) => r.level],
|
|
162
|
+
['STATUS', (r) => r.status],
|
|
163
|
+
['DEFAULT REP', (r) => (r.defaultRepresentative ? 'yes' : 'no')],
|
|
164
|
+
['ID', (r) => r.id],
|
|
165
|
+
])
|
|
166
|
+
console.log()
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
.demandCommand(1, 'Use: user search | user get | user create | user partners')
|
|
170
|
+
.strict(),
|
|
171
|
+
handler: () => {},
|
|
172
|
+
}
|
package/lib/users.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// users.js — user + partner-membership operations against a target environment (the local env by
|
|
2
|
+
// default, or any k8s namespace). Reads and writes go straight to the service_accounts schema over the
|
|
3
|
+
// same self-healing connection the copy engine uses (lib/engine/seed.js `connect`), which already knows
|
|
4
|
+
// how to reach both the local Postgres and a namespace's dooer-database.
|
|
5
|
+
//
|
|
6
|
+
// Why SQL and not the service-accounts HTTP API:
|
|
7
|
+
// * there is NO user-create route at all — users only ever appear via the signup flows, which also
|
|
8
|
+
// create a company; that is exactly what we do NOT want here.
|
|
9
|
+
// * nothing subscribes to a user-created event (checked across every service's eventStream.handle),
|
|
10
|
+
// so a plain INSERT leaves no consumer un-notified. Contrast `customer new`, which goes through the
|
|
11
|
+
// ledger API precisely because service-closing builds its periods off the emitted event.
|
|
12
|
+
// * the partner-membership route (PUT /v1/partners/:partnerId/users/:userId) is a bare upsert with one
|
|
13
|
+
// guard — the user must be of type `hi`. We reproduce that guard below rather than dropping it, so
|
|
14
|
+
// the CLI cannot create rows the product would have refused.
|
|
15
|
+
const crypto = require('crypto')
|
|
16
|
+
const bcrypt = require('bcryptjs')
|
|
17
|
+
const { connect } = require('./engine/seed')
|
|
18
|
+
|
|
19
|
+
const SCHEMA = 'service_accounts'
|
|
20
|
+
const T_USERS = `"${SCHEMA}".users`
|
|
21
|
+
const T_PARTNER = `"${SCHEMA}".partner`
|
|
22
|
+
const T_PARTNER_USER = `"${SCHEMA}"."partnerUser"`
|
|
23
|
+
|
|
24
|
+
// service_accounts.user_roles_at_dooer. `hi` = the accounting-professional user a partner can employ;
|
|
25
|
+
// `hiab` exists in the table but is legacy — not offered as a choice.
|
|
26
|
+
const USER_ROLES = ['hi', 'customer', 'admin']
|
|
27
|
+
// service_accounts."partnerUser_role_enum" — the "level" within a partner.
|
|
28
|
+
const PARTNER_LEVELS = ['member', 'admin']
|
|
29
|
+
|
|
30
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
31
|
+
|
|
32
|
+
// ── target environment ───────────────────────────────────────────────────────
|
|
33
|
+
// Default is the LOCAL env. `local` is a pseudo-namespace inside the engine, not a k8s one, so it is
|
|
34
|
+
// addressed with --target-local and rejected as a --target-namespace value (same split as customer copy).
|
|
35
|
+
function targetOf(argv) {
|
|
36
|
+
if (argv.targetNamespace === 'local') {
|
|
37
|
+
throw new Error('use --target-local for the local env, not --target-namespace local')
|
|
38
|
+
}
|
|
39
|
+
if (argv.targetLocal && argv.targetNamespace) {
|
|
40
|
+
throw new Error('pass either --target-local or --target-namespace, not both')
|
|
41
|
+
}
|
|
42
|
+
return argv.targetNamespace || 'local'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Writing users into the production cluster is possible but must never be a slip of the shell.
|
|
46
|
+
function guardWrite(namespace, argv) {
|
|
47
|
+
if (namespace === 'dooer-production' && !argv.confirmProduction) {
|
|
48
|
+
throw new Error('refusing to write to dooer-production without --confirm-production')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── password ────────────────────────────────────────────────────────────────
|
|
53
|
+
// Ambiguous glyphs (0/O, 1/l/I) are left out — these get read off a screen and typed into a login form.
|
|
54
|
+
const ALPHABET = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
|
55
|
+
|
|
56
|
+
function generatePassword(groups = 4, size = 5) {
|
|
57
|
+
const chunk = () => Array.from({ length: size }, () => ALPHABET[crypto.randomInt(ALPHABET.length)]).join('')
|
|
58
|
+
return Array.from({ length: groups }, chunk).join('-')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// service-accounts' own rule (lib/verify-password.js) is length >= 8 and nothing else; enforce exactly
|
|
62
|
+
// that so a password this CLI accepts is a password the login accepts.
|
|
63
|
+
function hashPassword(password) {
|
|
64
|
+
if (password.length < 8) throw new Error('password must be at least 8 characters (service-accounts rule)')
|
|
65
|
+
// bcryptjs, not bcrypt: identical wire format, but pure JS — a native build would break `npx`.
|
|
66
|
+
// get-login-user.js rewrites a stored `$2b$` prefix to `$2a$` before comparing, so either emits fine.
|
|
67
|
+
return bcrypt.hashSync(password, 10)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── personnummer ────────────────────────────────────────────────────────────
|
|
71
|
+
// Stored as 12 bare digits (verified against staging: every non-null id_number is length 12).
|
|
72
|
+
function normalizePersonnummer(input) {
|
|
73
|
+
const digits = String(input).replace(/\D/g, '')
|
|
74
|
+
if (digits.length === 12) return digits
|
|
75
|
+
if (digits.length === 10) {
|
|
76
|
+
// YY > current two-digit year means last century — the usual short-form expansion.
|
|
77
|
+
const yy = Number(digits.slice(0, 2))
|
|
78
|
+
const nowYY = new Date().getFullYear() % 100
|
|
79
|
+
return (yy > nowYY ? '19' : '20') + digits
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`personnummer must be 10 or 12 digits (got ${digits.length}): ${input}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── name defaults ───────────────────────────────────────────────────────────
|
|
85
|
+
// first_name/last_name are NOT NULL with no default. Derive something sane from the address so the
|
|
86
|
+
// common case is just `--email`.
|
|
87
|
+
function namesFromEmail(email) {
|
|
88
|
+
const local = email.split('@')[0]
|
|
89
|
+
const parts = local.split(/[._-]+/).filter(Boolean)
|
|
90
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
91
|
+
return {
|
|
92
|
+
firstName: cap(parts[0] || 'Test'),
|
|
93
|
+
lastName: cap(parts.slice(1).join(' ') || 'User'),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── reads ───────────────────────────────────────────────────────────────────
|
|
98
|
+
const USER_COLUMNS = `users_pk AS id, email, first_name, last_name,
|
|
99
|
+
fk_user_roles_at_dooer_pk AS role, id_number AS personnummer, email_verified,
|
|
100
|
+
inactivated_at, internal, language, inserted_at, (password_hash IS NOT NULL) AS has_password`
|
|
101
|
+
|
|
102
|
+
// A user is addressed by uuid or by email — nobody remembers uuids.
|
|
103
|
+
async function findUser(db, ref) {
|
|
104
|
+
const byId = UUID_RE.test(ref)
|
|
105
|
+
const { rows } = await db.query(
|
|
106
|
+
`SELECT ${USER_COLUMNS} FROM ${T_USERS} WHERE ${byId ? 'users_pk = $1' : 'lower(email) = lower($1)'}
|
|
107
|
+
ORDER BY inserted_at`,
|
|
108
|
+
[ref]
|
|
109
|
+
)
|
|
110
|
+
if (!rows.length) throw new Error(`no user matching "${ref}"`)
|
|
111
|
+
if (rows.length > 1) {
|
|
112
|
+
// email is unique per ROLE (users_email_fk_user_roles_at_dooer_pk_key), so one address can legitimately
|
|
113
|
+
// exist as both a `customer` and an `hi`. Make the caller say which.
|
|
114
|
+
throw new Error(
|
|
115
|
+
`"${ref}" matches ${rows.length} users (one per role: ${rows.map((r) => r.role).join(', ')}) — ` +
|
|
116
|
+
`address it by id: ${rows.map((r) => r.id).join(', ')}`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return rows[0]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function searchUsers(db, { query, role, limit }) {
|
|
123
|
+
const where = []
|
|
124
|
+
const params = []
|
|
125
|
+
if (query) {
|
|
126
|
+
params.push(`%${query}%`)
|
|
127
|
+
where.push(`(email ILIKE $${params.length} OR first_name ILIKE $${params.length}
|
|
128
|
+
OR last_name ILIKE $${params.length} OR id_number ILIKE $${params.length}
|
|
129
|
+
OR users_pk::text ILIKE $${params.length})`)
|
|
130
|
+
}
|
|
131
|
+
if (role) {
|
|
132
|
+
params.push(role)
|
|
133
|
+
where.push(`fk_user_roles_at_dooer_pk = $${params.length}`)
|
|
134
|
+
}
|
|
135
|
+
params.push(limit)
|
|
136
|
+
const { rows } = await db.query(
|
|
137
|
+
`SELECT ${USER_COLUMNS} FROM ${T_USERS}
|
|
138
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
139
|
+
ORDER BY inserted_at DESC LIMIT $${params.length}`,
|
|
140
|
+
params
|
|
141
|
+
)
|
|
142
|
+
return rows
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function listPartners(db) {
|
|
146
|
+
const { rows } = await db.query(
|
|
147
|
+
`SELECT p.id, p.name, p.domain, p.status, p."isDefaultPartner",
|
|
148
|
+
count(pu.id)::int AS users
|
|
149
|
+
FROM ${T_PARTNER} p LEFT JOIN ${T_PARTNER_USER} pu ON pu."partnerId" = p.id
|
|
150
|
+
GROUP BY p.id ORDER BY p.name`
|
|
151
|
+
)
|
|
152
|
+
return rows
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Same shape as GET /v1/partners/views/by-user/:userId, plus the membership level.
|
|
156
|
+
async function partnersForUser(db, userId) {
|
|
157
|
+
const { rows } = await db.query(
|
|
158
|
+
`SELECT p.id, p.name, p.domain, pu.role AS level, pu.status,
|
|
159
|
+
pu."inProduction", pu."defaultRepresentative", pu."lastLoginAt"
|
|
160
|
+
FROM ${T_PARTNER_USER} pu JOIN ${T_PARTNER} p ON p.id = pu."partnerId"
|
|
161
|
+
WHERE pu."userId" = $1 ORDER BY p.name`,
|
|
162
|
+
[userId]
|
|
163
|
+
)
|
|
164
|
+
return rows
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function findPartner(db, ref) {
|
|
168
|
+
const byId = UUID_RE.test(ref)
|
|
169
|
+
const { rows } = await db.query(
|
|
170
|
+
`SELECT id, name, domain, status FROM ${T_PARTNER}
|
|
171
|
+
WHERE ${byId ? 'id = $1' : '(lower(name) = lower($1) OR lower(domain) = lower($1))'} ORDER BY name`,
|
|
172
|
+
[ref]
|
|
173
|
+
)
|
|
174
|
+
if (!rows.length) throw new Error(`no partner matching "${ref}" (see: dooer-test-env partner list)`)
|
|
175
|
+
if (rows.length > 1) {
|
|
176
|
+
throw new Error(`"${ref}" matches ${rows.length} partners — address it by id: ${rows.map((r) => r.id).join(', ')}`)
|
|
177
|
+
}
|
|
178
|
+
return rows[0]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── writes ──────────────────────────────────────────────────────────────────
|
|
182
|
+
async function createUser(db, { email, password, role, personnummer, firstName, lastName, execute }) {
|
|
183
|
+
// Enforced by users_email_fk_user_roles_at_dooer_pk_key — check first so the failure is a sentence
|
|
184
|
+
// rather than a constraint-violation dump.
|
|
185
|
+
const {
|
|
186
|
+
rows: clash,
|
|
187
|
+
} = await db.query(
|
|
188
|
+
`SELECT users_pk FROM ${T_USERS} WHERE lower(email) = lower($1) AND fk_user_roles_at_dooer_pk = $2`,
|
|
189
|
+
[email, role]
|
|
190
|
+
)
|
|
191
|
+
if (clash.length) throw new Error(`a "${role}" user with email ${email} already exists (${clash[0].users_pk})`)
|
|
192
|
+
|
|
193
|
+
const hash = hashPassword(password)
|
|
194
|
+
const apiUuid = crypto.randomUUID()
|
|
195
|
+
// email_verified: these are test accounts — an unverified address puts a verification wall in front of
|
|
196
|
+
// every flow you actually wanted to exercise.
|
|
197
|
+
const values = [role, firstName, lastName, email, personnummer || null, hash, apiUuid]
|
|
198
|
+
// No id yet on a dry-run — the caller must not go on to use it as one (a placeholder string blew up the
|
|
199
|
+
// partner lookup's uuid parameter).
|
|
200
|
+
if (!execute) return { id: null, apiUuid }
|
|
201
|
+
const { rows } = await db.query(
|
|
202
|
+
`INSERT INTO ${T_USERS}
|
|
203
|
+
(fk_user_roles_at_dooer_pk, first_name, last_name, email, id_number, password_hash, api_uuid,
|
|
204
|
+
email_verified)
|
|
205
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
|
206
|
+
RETURNING users_pk AS id, api_uuid`,
|
|
207
|
+
values
|
|
208
|
+
)
|
|
209
|
+
return { id: rows[0].id, apiUuid: rows[0].api_uuid }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function assignPartner(db, { user, partner, level, execute }) {
|
|
213
|
+
// The guard from setPartnerUser: partner membership is for accounting professionals only. Without it
|
|
214
|
+
// the CLI would happily write a row that HQ then trips over.
|
|
215
|
+
if (user.role !== 'hi') {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`only "hi" users can belong to a partner (${user.email} is "${user.role}") — ` +
|
|
218
|
+
`this is the same invalid-user-type check the service-accounts API applies`
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
const {
|
|
222
|
+
rows: existing,
|
|
223
|
+
} = await db.query(`SELECT role AS level FROM ${T_PARTNER_USER} WHERE "partnerId" = $1 AND "userId" = $2`, [
|
|
224
|
+
partner.id,
|
|
225
|
+
user.id,
|
|
226
|
+
])
|
|
227
|
+
if (!execute) return { previous: existing[0] ? existing[0].level : null }
|
|
228
|
+
const { rows } = await db.query(
|
|
229
|
+
`INSERT INTO ${T_PARTNER_USER} ("partnerId", "userId", role, status)
|
|
230
|
+
VALUES ($1, $2, $3, 'enabled')
|
|
231
|
+
ON CONFLICT ("partnerId", "userId")
|
|
232
|
+
DO UPDATE SET role = EXCLUDED.role, status = EXCLUDED.status, "updatedAt" = now()
|
|
233
|
+
RETURNING role AS level`,
|
|
234
|
+
[partner.id, user.id, level]
|
|
235
|
+
)
|
|
236
|
+
return { previous: existing[0] ? existing[0].level : null, level: rows[0].level }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function unassignPartner(db, { user, partner, execute }) {
|
|
240
|
+
const {
|
|
241
|
+
rows: existing,
|
|
242
|
+
} = await db.query(`SELECT role AS level FROM ${T_PARTNER_USER} WHERE "partnerId" = $1 AND "userId" = $2`, [
|
|
243
|
+
partner.id,
|
|
244
|
+
user.id,
|
|
245
|
+
])
|
|
246
|
+
if (!existing.length) throw new Error(`${user.email} is not a member of ${partner.name}`)
|
|
247
|
+
if (execute) {
|
|
248
|
+
await db.query(`DELETE FROM ${T_PARTNER_USER} WHERE "partnerId" = $1 AND "userId" = $2`, [partner.id, user.id])
|
|
249
|
+
}
|
|
250
|
+
return { level: existing[0].level }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Every command opens one connection and closes it, so the CLI never leaves a socket to the cluster open.
|
|
254
|
+
async function withDb(namespace, fn) {
|
|
255
|
+
const db = await connect(namespace)
|
|
256
|
+
try {
|
|
257
|
+
return await fn(db)
|
|
258
|
+
} finally {
|
|
259
|
+
try {
|
|
260
|
+
await db.client.end()
|
|
261
|
+
} catch (_) {
|
|
262
|
+
/* already gone */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
USER_ROLES,
|
|
269
|
+
PARTNER_LEVELS,
|
|
270
|
+
targetOf,
|
|
271
|
+
guardWrite,
|
|
272
|
+
generatePassword,
|
|
273
|
+
hashPassword,
|
|
274
|
+
normalizePersonnummer,
|
|
275
|
+
namesFromEmail,
|
|
276
|
+
findUser,
|
|
277
|
+
searchUsers,
|
|
278
|
+
listPartners,
|
|
279
|
+
partnersForUser,
|
|
280
|
+
findPartner,
|
|
281
|
+
createUser,
|
|
282
|
+
assignPartner,
|
|
283
|
+
unassignPartner,
|
|
284
|
+
withDb,
|
|
285
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Run the whole Dooer backend locally (staging DB minus customers), copy/purge customers between environments, and shred — one CLI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": "Dooer/cli-dooer-test-env",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@aws-sdk/client-s3": "^3.600.0",
|
|
29
29
|
"@dooer/package-info": "^1.6.7",
|
|
30
|
+
"bcryptjs": "^3.0.3",
|
|
30
31
|
"chalk": "^4.1.2",
|
|
31
32
|
"js-yaml": "^3.14.1",
|
|
32
33
|
"pg": "^8.23.0",
|
package/readme.md
CHANGED
|
@@ -53,6 +53,8 @@ db roles (re)create per-service DB role
|
|
|
53
53
|
db snapshot <name> | db rollback <name> local restore points
|
|
54
54
|
customer new "<name>" --owner <userId> --execute empty functional account: company + Owner + subscriptions + partner dooer
|
|
55
55
|
customer copy | customer purge copy / delete one org between environments (emails anonymized)
|
|
56
|
+
user search | get | create | partners find/inspect/create users (local env unless --target-namespace)
|
|
57
|
+
partner list | users | assign | unassign partners and who belongs to them, at which level
|
|
56
58
|
shred anonymize the LOCAL db (localhost only)
|
|
57
59
|
bankid pull | status | clear BankID cert (staging → keychain), used by service-accounts
|
|
58
60
|
validation on | off | status output-schema validation (default OFF, like staging/live)
|
|
@@ -109,13 +111,66 @@ reads (`fiscalYear`, `hasVatRegistration`, `hasCompanyTax`, `hasEmployeeRegistra
|
|
|
109
111
|
`vatPeriod`/`vatDue`). `--no-fiscal-year` leaves all of that blank.
|
|
110
112
|
|
|
111
113
|
The owner must be a **`customer`** user — a `hi`/admin owner cannot accept Terms of Service and the command
|
|
112
|
-
warns you. Find one
|
|
114
|
+
warns you. Find one:
|
|
113
115
|
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
```bash
|
|
117
|
+
npx @dooer/dooer-test-env@latest user search --role customer
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Create a user you can actually log in as
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# an accounting professional (role `hi`) in the local env, password generated and printed once
|
|
124
|
+
npx @dooer/dooer-test-env@latest user create --email anna@dooer.com --execute
|
|
125
|
+
|
|
126
|
+
# …a customer instead, with your own password and a personnummer
|
|
127
|
+
npx @dooer/dooer-test-env@latest user create --email kim@example.com \
|
|
128
|
+
--role customer --password 'hunter2hunter2' --personnummer 19900101-1234 --execute
|
|
129
|
+
|
|
130
|
+
# …or an `hi` user placed straight into a partner
|
|
131
|
+
npx @dooer/dooer-test-env@latest user create --email anna@dooer.com \
|
|
132
|
+
--partner "Dooer Devteam" --level admin --execute
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The password is stored the way signup stores it (bcrypt, cost 10), so it works on the ordinary
|
|
136
|
+
email+password login — no BankID needed. It is shown **once**; there is no way to read it back. `--role`
|
|
137
|
+
defaults to `hi` (other choices: `customer`, `admin`), names default to something derived from the address,
|
|
138
|
+
and the account is created with its email pre-verified so no verification wall gets in the way.
|
|
139
|
+
|
|
140
|
+
Look users up by email **or** id, and see where they belong:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npx @dooer/dooer-test-env@latest user search anna # email, name, personnummer or id
|
|
144
|
+
npx @dooer/dooer-test-env@latest user get anna@dooer.com # details + partner memberships
|
|
117
145
|
```
|
|
118
146
|
|
|
147
|
+
### Partners and who belongs to them
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npx @dooer/dooer-test-env@latest partner list # every partner + user counts
|
|
151
|
+
npx @dooer/dooer-test-env@latest partner users "Dooer Devteam" # its users and their level
|
|
152
|
+
|
|
153
|
+
# add / promote / remove (a partner is addressable by id, name or domain)
|
|
154
|
+
npx @dooer/dooer-test-env@latest partner assign anna@dooer.com "Dooer Devteam" --level admin --execute
|
|
155
|
+
npx @dooer/dooer-test-env@latest partner unassign anna@dooer.com "Dooer Devteam" --execute
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`--level` is the membership level inside the partner (`member` or `admin`) — not the same thing as the
|
|
159
|
+
user's `--role` at Dooer. Only **`hi`** users can belong to a partner; the CLI applies the same
|
|
160
|
+
`invalid-user-type` check the service-accounts API does rather than writing a row HQ would choke on.
|
|
161
|
+
|
|
162
|
+
### Look at (or fix) users in a real environment
|
|
163
|
+
|
|
164
|
+
These commands default to the local env. Point them at a cluster with `--target-namespace`:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npx @dooer/dooer-test-env@latest user search someone@dooer.com --target-namespace dooer-staging
|
|
168
|
+
npx @dooer/dooer-test-env@latest partner list --target-namespace dooer-production
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Writes there are still dry-run until `--execute`, and `dooer-production` additionally demands
|
|
172
|
+
`--confirm-production`.
|
|
173
|
+
|
|
119
174
|
### Copy a real customer into the local env
|
|
120
175
|
|
|
121
176
|
`--target-local` addresses this stack (Postgres on 55432 + MinIO); the source can be any k8s namespace
|