@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.
@@ -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
- The VPN part is self-service. The kubectl part needs an admin to sign your certificate see
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 go jq kubernetes-cli
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 && go version && jq --version && kubectl version --client && openssl version
24
+ wg --version && kubectl version --client && openssl version
24
25
  ```
25
26
 
26
27
  ---
27
28
 
28
29
  ## 1. VPN
29
30
 
30
- ### 1a. Get a Robo10 token
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 VPN API is authenticated with a Robo10 access token. This opens a browser **log in with your
33
- `@robo10.com` email**.
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
- ```bash
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
- 1. Open <https://chat.roboten.com> and log in.
50
- 2. DevTools **Application** **Storage Cookies**.
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
- NAME="$(whoami)-$(hostname -s)"
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
- > This file contains your **private key**. Treat it like a password: never commit it, never paste it into
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 ~/roboten.conf /opt/homebrew/etc/wireguard/roboten.conf
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 ~/roboten.conf # the copy under /opt/homebrew is the one wg-quick reads
53
+ rm ~/Downloads/<the-file-they-sent>.conf # the copy under /opt/homebrew is the one wg-quick reads
95
54
  ```
96
55
 
97
- Check first whether that name is already taken — **do not overwrite someone else's tunnel**:
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 a sleep/wake)
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
- ### 1f. Verify the VPN works
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 (1e). Until this passes, no
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
- Authentication is a **client certificate**. You generate the key and the signing request; only the request
140
- leaves your machine. Your private key never does.
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
- ### 2a. Generate your key and CSR
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
- Use `<firstname>-admin` as the name — that string becomes your username in the cluster, and it is what the
145
- admin binds permissions to.
102
+ ### 2a. Install it
146
103
 
147
- ```bash
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
- openssl genrsa -out "$USER_NAME.key" 2048
152
- openssl req -new -key "$USER_NAME.key" -out "$USER_NAME.csr" -subj "/CN=$USER_NAME"
153
- chmod 600 "$USER_NAME.key"
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
- ### 2b. Send the CSR to an admin
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
- cd ~/.kube/roboten
175
- USER_NAME=<firstname>-admin
176
- SERVER=https://k8s.roboten-infra.com:64430
177
-
178
- KUBECONFIG=~/.kube/config kubectl config set-cluster kubernetes \
179
- --server="$SERVER" --certificate-authority=ca.crt --embed-certs=true
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
- KUBECONFIG=~/.kube/config kubectl config set-context "$USER_NAME@kubernetes" \
185
- --cluster=kubernetes --user="$USER_NAME"
123
+ Then select the context (the admin will tell you its name, `<firstname>-admin@kubernetes`):
186
124
 
187
- KUBECONFIG=~/.kube/config kubectl config use-context "$USER_NAME@kubernetes"
125
+ ```bash
126
+ kubectl config get-contexts
127
+ kubectl config use-context <firstname>-admin@kubernetes
188
128
  ```
189
129
 
190
- `--embed-certs=true` copies the certificates *into* `~/.kube/config`, so the files in `~/.kube/roboten` are
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 (1e) |
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)` | Certificate is wrong/expired, or the cluster does not know that CN — back to 2b |
234
- | `Error from server (Forbidden)` | You *are* authenticated; your user has no permissions bound yet — ask the admin to complete their step 3 |
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 you repeat section 2 the admin only has to
239
- re-sign; your permissions (step 3 on their side) stay in place.
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.
@@ -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** | self-service | make sure they have a Robo10 login; nothing to hand out |
8
- | **kubectl credential** | needs you | sign their CSR and bind their permissions |
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 the VPN up.
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 — what you actually have to do
14
+ ## 1. VPN — create and hand over a tunnel
15
15
 
16
- Tunnels are **self-service**: any valid Robo10 token can create one, and the config is generated on demand
17
- by the VPN API. So there is no artefact for you to produce or send.
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
- Your only job is to make sure the person can log in at <https://chat.roboten.com> with their
20
- **`@robo10.com`** account — that same identity is what the token flow authenticates. If they cannot log in,
21
- that is an account-provisioning issue in Robo10, not something this guide covers.
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
- **Never** create the tunnel on their behalf and send them the config: the config embeds a private key, so a
26
- tunnel they generated themselves is the only one only they hold.
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
- To see who has tunnels (with your own token, as in the dev guide):
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
- To revoke one `DELETE` is not in the VPN service's own docs, but it works (verified 2026-09-04, returns
35
- `204` and the tunnel disappears from the list):
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's key server-side, so it is the real off-switch for VPN access when someone
43
- leaves — do it *and* revoke their kubectl permissions (§3).
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 — sign their certificate
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 never see their private key. They send you a `.csr`; you send back a `.crt`.
104
+ You do all of it and hand over a finished `kubeconfig`.
53
105
 
54
- ### 2a. Submit their CSR to the cluster
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 # must match the CN in their CSR
58
- CSR_FILE=~/Downloads/$USER_NAME.csr # what they sent you
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 "$CSR_FILE" | tr -d '\n')
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
- Sanity-check the CN is what you expect before approving — the CN is the identity you are about to grant
75
- admin to:
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
- ### 2b. Approve and extract the certificate
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, the signer has not run yet — wait a couple of seconds and repeat. If it
94
- stays empty, the CSR was approved but not signed; check `kubectl describe csr "$USER_NAME"`.
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
- The same file for everyone:
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
- ### 2d. Bind permissions
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
- ### 2e. Hand back
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
- Send them three things (none are secret the secret half never left their machine):
192
+ `--embed-certs=true` inlines the key and certificates, so the file you hand over is self-contained:
131
193
 
132
- 1. `<firstname>-admin.crt`
133
- 2. `ca.crt`
134
- 3. the API server URL — currently `https://k8s.roboten-infra.com:64430`
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
- Then they follow [getting-access.md § 2c](./getting-access.md#2c-assemble-your-kubeconfig).
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
- ### 2f. Verify before you tell them it is done
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 get clusterrolebinding | grep "$USER_NAME" # expect 5 rows
142
- kubectl auth can-i get pods -n dooer-production --as "$USER_NAME"
143
- kubectl auth can-i get secrets -n dooer-staging --as "$USER_NAME"
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
- `--as` impersonates the user, so you are testing the real RBAC without needing their certificate.
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: the user repeats [getting-access.md § 2a–2c](./getting-access.md#2a-generate-your-key-and-csr)
173
- and you repeat **2a–2c and 2e** here. Skip 2d — their ClusterRoleBindings are bound to the username, not to
174
- the certificate, so they survive a re-issue untouched.
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.12.0",
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 with:
114
+ warns you. Find one:
113
115
 
114
- ```sql
115
- SELECT users_pk, email FROM service_accounts.users
116
- WHERE fk_user_roles_at_dooer_pk = 'customer' AND inactivated_at IS NULL LIMIT 5;
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