Merge pull request #141 from bnema/security-hardening
Security hardening & token-only auth
31 files · +982 -1030
M docs/cli/auth.md +39 -75
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Authentication Commands | |
| 2 | 2 | ||
| 3 | - | Manage Gordon server authentication tokens and passwords. | |
| 3 | + | Manage Gordon server authentication tokens. | |
| 4 | 4 | ||
| 5 | 5 | ## gordon auth | |
| 6 | 6 | ||
| @@ -12,14 +12,13 @@ Manage Gordon server authentication tokens and passwords. | |||
| 12 | 12 | | `token generate` | Generate a new JWT token | | |
| 13 | 13 | | `token list` | List all stored tokens | | |
| 14 | 14 | | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) | | |
| 15 | - | | `password hash` | Generate bcrypt password hash | | |
| 16 | 15 | | `internal` | Show internal registry credentials | | |
| 17 | 16 | ||
| 18 | 17 | --- | |
| 19 | 18 | ||
| 20 | 19 | ## gordon auth login | |
| 21 | 20 | ||
| 22 | - | Authenticate with a remote Gordon server using password authentication or a pre-generated token. | |
| 21 | + | Authenticate with a remote Gordon server using a pre-generated token. | |
| 23 | 22 | ||
| 24 | 23 | ```bash | |
| 25 | 24 | gordon auth login [options] | |
| @@ -30,34 +29,22 @@ gordon auth login [options] | |||
| 30 | 29 | | Option | Description | | |
| 31 | 30 | |--------|-------------| | |
| 32 | 31 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | |
| 33 | - | | `-u, --username` | Username for authentication | | |
| 34 | - | | `-p, --password` | Password for authentication | | |
| 35 | 32 | | `-t, --token` | Authentication token to store for the remote | | |
| 36 | 33 | ||
| 37 | 34 | ### Description | |
| 38 | 35 | ||
| 39 | - | This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning. | |
| 36 | + | This command stores a pre-generated token for use with a remote Gordon server. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning. | |
| 40 | 37 | ||
| 41 | - | If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis. | |
| 42 | - | ||
| 43 | - | The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote. | |
| 44 | - | ||
| 45 | - | > **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`. | |
| 38 | + | The token is verified against `/admin/status` on a best-effort basis. | |
| 46 | 39 | ||
| 47 | 40 | ### Examples | |
| 48 | 41 | ||
| 49 | 42 | ```bash | |
| 50 | - | # Login using active remote | |
| 51 | - | gordon auth login | |
| 52 | - | ||
| 53 | - | # Login to specific remote | |
| 54 | - | gordon auth login --remote prod | |
| 55 | - | ||
| 56 | - | # Pre-fill username | |
| 57 | - | gordon auth login --username admin | |
| 58 | - | ||
| 59 | - | # Store a pre-generated token | |
| 43 | + | # Store a token for the active remote | |
| 60 | 44 | gordon auth login --token <token> | |
| 45 | + | ||
| 46 | + | # Store a token for a specific remote | |
| 47 | + | gordon auth login --remote prod --token <token> | |
| 61 | 48 | ``` | |
| 62 | 49 | ||
| 63 | 50 | ### Output | |
| @@ -65,26 +52,14 @@ gordon auth login --token <token> | |||
| 65 | 52 | When `pass` is available: | |
| 66 | 53 | ||
| 67 | 54 | ```text | |
| 68 | - | Username: admin | |
| 69 | - | Password: ******** | |
| 70 | - | Authenticating with prod... | |
| 71 | - | ||
| 72 | - | ✓ Authentication successful! | |
| 73 | 55 | Token stored in pass for remote 'prod' | |
| 74 | - | Expires in: 604800 seconds | |
| 75 | 56 | ``` | |
| 76 | 57 | ||
| 77 | 58 | When `pass` is not available: | |
| 78 | 59 | ||
| 79 | 60 | ```text | |
| 80 | - | Username: admin | |
| 81 | - | Password: ******** | |
| 82 | - | Authenticating with prod... | |
| 83 | - | ||
| 84 | 61 | Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage. | |
| 85 | - | ✓ Authentication successful! | |
| 86 | 62 | Token stored for remote 'prod' | |
| 87 | - | Expires in: 604800 seconds | |
| 88 | 63 | ``` | |
| 89 | 64 | ||
| 90 | 65 | --- | |
| @@ -121,6 +96,18 @@ Compound durations are also supported: `1y6M`, `2w3d`, `1d12h` | |||
| 121 | 96 | ||
| 122 | 97 | Standard Go durations also work: `24h`, `30m`, `1h30m` | |
| 123 | 98 | ||
| 99 | + | ### Scopes | |
| 100 | + | ||
| 101 | + | Registry scopes: `push`, `pull`, `push,pull` | |
| 102 | + | ||
| 103 | + | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write` | |
| 104 | + | ||
| 105 | + | Combine scopes with commas: | |
| 106 | + | ||
| 107 | + | ```bash | |
| 108 | + | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | |
| 109 | + | ``` | |
| 110 | + | ||
| 124 | 111 | ### Examples | |
| 125 | 112 | ||
| 126 | 113 | ```bash | |
| @@ -136,8 +123,11 @@ gordon auth token generate --subject builder --scopes push --expiry 1y | |||
| 136 | 123 | # Temporary token (24 hours) | |
| 137 | 124 | gordon auth token generate --subject temp --expiry 24h | |
| 138 | 125 | ||
| 139 | - | # Token for 6 months | |
| 140 | - | gordon auth token generate --subject deploy --expiry 6M | |
| 126 | + | # Full admin access token | |
| 127 | + | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 6M | |
| 128 | + | ||
| 129 | + | # Read-only admin token | |
| 130 | + | gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d | |
| 141 | 131 | ``` | |
| 142 | 132 | ||
| 143 | 133 | ### Output | |
| @@ -199,33 +189,6 @@ Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |||
| 199 | 189 | ||
| 200 | 190 | --- | |
| 201 | 191 | ||
| 202 | - | ## gordon auth password hash | |
| 203 | - | ||
| 204 | - | Generate a bcrypt hash for password authentication. | |
| 205 | - | ||
| 206 | - | ```bash | |
| 207 | - | gordon auth password hash | |
| 208 | - | ``` | |
| 209 | - | ||
| 210 | - | Interactively prompts for a password and outputs the bcrypt hash. | |
| 211 | - | ||
| 212 | - | ### Output | |
| 213 | - | ||
| 214 | - | ``` | |
| 215 | - | Enter password: ******** | |
| 216 | - | ||
| 217 | - | Bcrypt hash (store in your secrets backend): | |
| 218 | - | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |
| 219 | - | ||
| 220 | - | Then reference the path in your config: | |
| 221 | - | [auth] | |
| 222 | - | username = "deploy" | |
| 223 | - | password_hash = "gordon/auth/password_hash" | |
| 224 | - | token_secret = "gordon/auth/token_secret" | |
| 225 | - | ``` | |
| 226 | - | ||
| 227 | - | --- | |
| 228 | - | ||
| 229 | 192 | ## gordon auth internal | |
| 230 | 193 | ||
| 231 | 194 | Display the auto-generated internal registry credentials. | |
| @@ -272,6 +235,14 @@ Usage: | |||
| 272 | 235 | | `push` | Push images to registry | | |
| 273 | 236 | | `pull` | Pull images from registry | | |
| 274 | 237 | | `push,pull` | Both push and pull (default) | | |
| 238 | + | | `admin:*:*` | Full admin access | | |
| 239 | + | | `admin:routes:read` | Read-only routes access | | |
| 240 | + | | `admin:routes:write` | Routes write access | | |
| 241 | + | | `admin:config:read` | Read-only config access | | |
| 242 | + | | `admin:config:write` | Config write access | | |
| 243 | + | | `admin:status:read` | Read-only status/health | | |
| 244 | + | | `admin:secrets:read` | List secret keys | | |
| 245 | + | | `admin:secrets:write` | Set/delete secrets | | |
| 275 | 246 | ||
| 276 | 247 | ## Token Expiry Formats | |
| 277 | 248 | ||
| @@ -293,8 +264,8 @@ Usage: | |||
| 293 | 264 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |
| 294 | 265 | ||
| 295 | 266 | # Save token as CI secret | |
| 296 | - | # GitHub: Settings → Secrets → New repository secret | |
| 297 | - | # GitLab: Settings → CI/CD → Variables | |
| 267 | + | # GitHub: Settings > Secrets > New repository secret | |
| 268 | + | # GitLab: Settings > CI/CD > Variables | |
| 298 | 269 | ``` | |
| 299 | 270 | ||
| 300 | 271 | ### Docker Login | |
| @@ -319,17 +290,9 @@ gordon auth token generate --subject ci-bot --expiry 0 | |||
| 319 | 290 | gordon auth token revoke <old-token-id> | |
| 320 | 291 | ``` | |
| 321 | 292 | ||
| 322 | - | ### Password Setup | |
| 293 | + | ### Token-only Setup | |
| 323 | 294 | ||
| 324 | 295 | ```bash | |
| 325 | - | # Generate hash | |
| 326 | - | gordon auth password hash | |
| 327 | - | # Enter: mypassword | |
| 328 | - | ||
| 329 | - | # Store in pass | |
| 330 | - | pass insert gordon/auth/password_hash | |
| 331 | - | # Paste: $2a$10$... | |
| 332 | - | ||
| 333 | 296 | # Create token secret | |
| 334 | 297 | pass insert gordon/auth/token_secret | |
| 335 | 298 | # Enter a random 32+ character string | |
| @@ -338,9 +301,10 @@ pass insert gordon/auth/token_secret | |||
| 338 | 301 | [auth] | |
| 339 | 302 | enabled = true | |
| 340 | 303 | secrets_backend = "pass" | |
| 341 | - | username = "deploy" | |
| 342 | - | password_hash = "gordon/auth/password_hash" | |
| 343 | 304 | token_secret = "gordon/auth/token_secret" | |
| 305 | + | ||
| 306 | + | # Generate a deploy token | |
| 307 | + | gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0 | |
| 344 | 308 | ``` | |
| 345 | 309 | ||
| 346 | 310 | ## Related | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Authentication Commands | 1 | # Authentication Commands | ||
| 2 | 2 | ||||
| 3 | - | Manage Gordon server authentication tokens and passwords. | 3 | + | Manage Gordon server authentication tokens. |
| 4 | 4 | ||||
| 5 | ## gordon auth | 5 | ## gordon auth | ||
| 6 | 6 | ||||
| @@ -12,14 +12,13 @@ Manage Gordon server authentication tokens and passwords. | |||||
| 12 | | `token generate` | Generate a new JWT token | | 12 | | `token generate` | Generate a new JWT token | | ||
| 13 | | `token list` | List all stored tokens | | 13 | | `token list` | List all stored tokens | | ||
| 14 | | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) | | 14 | | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) | | ||
| 15 | - | | `password hash` | Generate bcrypt password hash | | |||
| 16 | | `internal` | Show internal registry credentials | | 15 | | `internal` | Show internal registry credentials | | ||
| 17 | 16 | ||||
| 18 | --- | 17 | --- | ||
| 19 | 18 | ||||
| 20 | ## gordon auth login | 19 | ## gordon auth login | ||
| 21 | 20 | ||||
| 22 | - | Authenticate with a remote Gordon server using password authentication or a pre-generated token. | 21 | + | Authenticate with a remote Gordon server using a pre-generated token. |
| 23 | 22 | ||||
| 24 | ```bash | 23 | ```bash | ||
| 25 | gordon auth login [options] | 24 | gordon auth login [options] | ||
| @@ -30,34 +29,22 @@ gordon auth login [options] | |||||
| 30 | | Option | Description | | 29 | | Option | Description | | ||
| 31 | |--------|-------------| | 30 | |--------|-------------| | ||
| 32 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | 31 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | ||
| 33 | - | | `-u, --username` | Username for authentication | | |||
| 34 | - | | `-p, --password` | Password for authentication | | |||
| 35 | | `-t, --token` | Authentication token to store for the remote | | 32 | | `-t, --token` | Authentication token to store for the remote | | ||
| 36 | 33 | ||||
| 37 | ### Description | 34 | ### Description | ||
| 38 | 35 | ||||
| 39 | - | This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning. | 36 | + | This command stores a pre-generated token for use with a remote Gordon server. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning. |
| 40 | 37 | ||||
| 41 | - | If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis. | 38 | + | The token is verified against `/admin/status` on a best-effort basis. |
| 42 | - | ||||
| 43 | - | The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote. | |||
| 44 | - | ||||
| 45 | - | > **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`. | |||
| 46 | 39 | ||||
| 47 | ### Examples | 40 | ### Examples | ||
| 48 | 41 | ||||
| 49 | ```bash | 42 | ```bash | ||
| 50 | - | # Login using active remote | 43 | + | # Store a token for the active remote |
| 51 | - | gordon auth login | |||
| 52 | - | ||||
| 53 | - | # Login to specific remote | |||
| 54 | - | gordon auth login --remote prod | |||
| 55 | - | ||||
| 56 | - | # Pre-fill username | |||
| 57 | - | gordon auth login --username admin | |||
| 58 | - | ||||
| 59 | - | # Store a pre-generated token | |||
| 60 | gordon auth login --token <token> | 44 | gordon auth login --token <token> | ||
| 45 | + | ||||
| 46 | + | # Store a token for a specific remote | |||
| 47 | + | gordon auth login --remote prod --token <token> | |||
| 61 | ``` | 48 | ``` | ||
| 62 | 49 | ||||
| 63 | ### Output | 50 | ### Output | ||
| @@ -65,26 +52,14 @@ gordon auth login --token <token> | |||||
| 65 | When `pass` is available: | 52 | When `pass` is available: | ||
| 66 | 53 | ||||
| 67 | ```text | 54 | ```text | ||
| 68 | - | Username: admin | |||
| 69 | - | Password: ******** | |||
| 70 | - | Authenticating with prod... | |||
| 71 | - | ||||
| 72 | - | ✓ Authentication successful! | |||
| 73 | Token stored in pass for remote 'prod' | 55 | Token stored in pass for remote 'prod' | ||
| 74 | - | Expires in: 604800 seconds | |||
| 75 | ``` | 56 | ``` | ||
| 76 | 57 | ||||
| 77 | When `pass` is not available: | 58 | When `pass` is not available: | ||
| 78 | 59 | ||||
| 79 | ```text | 60 | ```text | ||
| 80 | - | Username: admin | |||
| 81 | - | Password: ******** | |||
| 82 | - | Authenticating with prod... | |||
| 83 | - | ||||
| 84 | Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage. | 61 | Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage. | ||
| 85 | - | ✓ Authentication successful! | |||
| 86 | Token stored for remote 'prod' | 62 | Token stored for remote 'prod' | ||
| 87 | - | Expires in: 604800 seconds | |||
| 88 | ``` | 63 | ``` | ||
| 89 | 64 | ||||
| 90 | --- | 65 | --- | ||
| @@ -121,6 +96,18 @@ Compound durations are also supported: `1y6M`, `2w3d`, `1d12h` | |||||
| 121 | 96 | ||||
| 122 | Standard Go durations also work: `24h`, `30m`, `1h30m` | 97 | Standard Go durations also work: `24h`, `30m`, `1h30m` | ||
| 123 | 98 | ||||
| 99 | + | ### Scopes | |||
| 100 | + | ||||
| 101 | + | Registry scopes: `push`, `pull`, `push,pull` | |||
| 102 | + | ||||
| 103 | + | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write` | |||
| 104 | + | ||||
| 105 | + | Combine scopes with commas: | |||
| 106 | + | ||||
| 107 | + | ```bash | |||
| 108 | + | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | |||
| 109 | + | ``` | |||
| 110 | + | ||||
| 124 | ### Examples | 111 | ### Examples | ||
| 125 | 112 | ||||
| 126 | ```bash | 113 | ```bash | ||
| @@ -136,8 +123,11 @@ gordon auth token generate --subject builder --scopes push --expiry 1y | |||||
| 136 | # Temporary token (24 hours) | 123 | # Temporary token (24 hours) | ||
| 137 | gordon auth token generate --subject temp --expiry 24h | 124 | gordon auth token generate --subject temp --expiry 24h | ||
| 138 | 125 | ||||
| 139 | - | # Token for 6 months | 126 | + | # Full admin access token |
| 140 | - | gordon auth token generate --subject deploy --expiry 6M | 127 | + | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 6M |
| 128 | + | ||||
| 129 | + | # Read-only admin token | |||
| 130 | + | gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d | |||
| 141 | ``` | 131 | ``` | ||
| 142 | 132 | ||||
| 143 | ### Output | 133 | ### Output | ||
| @@ -199,33 +189,6 @@ Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked. | |||||
| 199 | 189 | ||||
| 200 | --- | 190 | --- | ||
| 201 | 191 | ||||
| 202 | - | ## gordon auth password hash | |||
| 203 | - | ||||
| 204 | - | Generate a bcrypt hash for password authentication. | |||
| 205 | - | ||||
| 206 | - | ```bash | |||
| 207 | - | gordon auth password hash | |||
| 208 | - | ``` | |||
| 209 | - | ||||
| 210 | - | Interactively prompts for a password and outputs the bcrypt hash. | |||
| 211 | - | ||||
| 212 | - | ### Output | |||
| 213 | - | ||||
| 214 | - | ``` | |||
| 215 | - | Enter password: ******** | |||
| 216 | - | ||||
| 217 | - | Bcrypt hash (store in your secrets backend): | |||
| 218 | - | $2a$10$N9qo8uLOickgx2ZMRZoMye... | |||
| 219 | - | ||||
| 220 | - | Then reference the path in your config: | |||
| 221 | - | [auth] | |||
| 222 | - | username = "deploy" | |||
| 223 | - | password_hash = "gordon/auth/password_hash" | |||
| 224 | - | token_secret = "gordon/auth/token_secret" | |||
| 225 | - | ``` | |||
| 226 | - | ||||
| 227 | - | --- | |||
| 228 | - | ||||
| 229 | ## gordon auth internal | 192 | ## gordon auth internal | ||
| 230 | 193 | ||||
| 231 | Display the auto-generated internal registry credentials. | 194 | Display the auto-generated internal registry credentials. | ||
| @@ -272,6 +235,14 @@ Usage: | |||||
| 272 | | `push` | Push images to registry | | 235 | | `push` | Push images to registry | | ||
| 273 | | `pull` | Pull images from registry | | 236 | | `pull` | Pull images from registry | | ||
| 274 | | `push,pull` | Both push and pull (default) | | 237 | | `push,pull` | Both push and pull (default) | | ||
| 238 | + | | `admin:*:*` | Full admin access | | |||
| 239 | + | | `admin:routes:read` | Read-only routes access | | |||
| 240 | + | | `admin:routes:write` | Routes write access | | |||
| 241 | + | | `admin:config:read` | Read-only config access | | |||
| 242 | + | | `admin:config:write` | Config write access | | |||
| 243 | + | | `admin:status:read` | Read-only status/health | | |||
| 244 | + | | `admin:secrets:read` | List secret keys | | |||
| 245 | + | | `admin:secrets:write` | Set/delete secrets | | |||
| 275 | 246 | ||||
| 276 | ## Token Expiry Formats | 247 | ## Token Expiry Formats | ||
| 277 | 248 | ||||
| @@ -293,8 +264,8 @@ Usage: | |||||
| 293 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | 264 | gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | ||
| 294 | 265 | ||||
| 295 | # Save token as CI secret | 266 | # Save token as CI secret | ||
| 296 | - | # GitHub: Settings → Secrets → New repository secret | 267 | + | # GitHub: Settings > Secrets > New repository secret |
| 297 | - | # GitLab: Settings → CI/CD → Variables | 268 | + | # GitLab: Settings > CI/CD > Variables |
| 298 | ``` | 269 | ``` | ||
| 299 | 270 | ||||
| 300 | ### Docker Login | 271 | ### Docker Login | ||
| @@ -319,17 +290,9 @@ gordon auth token generate --subject ci-bot --expiry 0 | |||||
| 319 | gordon auth token revoke <old-token-id> | 290 | gordon auth token revoke <old-token-id> | ||
| 320 | ``` | 291 | ``` | ||
| 321 | 292 | ||||
| 322 | - | ### Password Setup | 293 | + | ### Token-only Setup |
| 323 | 294 | ||||
| 324 | ```bash | 295 | ```bash | ||
| 325 | - | # Generate hash | |||
| 326 | - | gordon auth password hash | |||
| 327 | - | # Enter: mypassword | |||
| 328 | - | ||||
| 329 | - | # Store in pass | |||
| 330 | - | pass insert gordon/auth/password_hash | |||
| 331 | - | # Paste: $2a$10$... | |||
| 332 | - | ||||
| 333 | # Create token secret | 296 | # Create token secret | ||
| 334 | pass insert gordon/auth/token_secret | 297 | pass insert gordon/auth/token_secret | ||
| 335 | # Enter a random 32+ character string | 298 | # Enter a random 32+ character string | ||
| @@ -338,9 +301,10 @@ pass insert gordon/auth/token_secret | |||||
| 338 | [auth] | 301 | [auth] | ||
| 339 | enabled = true | 302 | enabled = true | ||
| 340 | secrets_backend = "pass" | 303 | secrets_backend = "pass" | ||
| 341 | - | username = "deploy" | |||
| 342 | - | password_hash = "gordon/auth/password_hash" | |||
| 343 | token_secret = "gordon/auth/token_secret" | 304 | token_secret = "gordon/auth/token_secret" | ||
| 305 | + | ||||
| 306 | + | # Generate a deploy token | |||
| 307 | + | gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0 | |||
| 344 | ``` | 308 | ``` | ||
| 345 | 309 | ||||
| 346 | ## Related | 310 | ## Related | ||
M docs/config/auth.md +24 -40
| @@ -4,29 +4,18 @@ Configure authentication for the Gordon registry and Admin API. | |||
| 4 | 4 | ||
| 5 | 5 | Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`). | |
| 6 | 6 | ||
| 7 | - | ## Pick a Mode | |
| 7 | + | ## Configuration | |
| 8 | 8 | ||
| 9 | - | ### Token-only (recommended for CI/CD) | |
| 9 | + | Gordon uses token-only authentication. All registry and Admin API access is controlled through JWT tokens. | |
| 10 | 10 | ||
| 11 | 11 | ```toml | |
| 12 | 12 | [auth] | |
| 13 | 13 | secrets_backend = "pass" | |
| 14 | 14 | token_secret = "gordon/auth/token_secret" | |
| 15 | 15 | token_expiry = "30d" | |
| 16 | + | access_token_ttl = "15m" | |
| 16 | 17 | ``` | |
| 17 | 18 | ||
| 18 | - | ### Password + Token (interactive + CI/CD) | |
| 19 | - | ||
| 20 | - | ```toml | |
| 21 | - | [auth] | |
| 22 | - | secrets_backend = "pass" | |
| 23 | - | username = "deploy" | |
| 24 | - | password_hash = "gordon/auth/password_hash" | |
| 25 | - | token_secret = "gordon/auth/token_secret" | |
| 26 | - | ``` | |
| 27 | - | ||
| 28 | - | Token auth always works. Adding `username` + `password_hash` enables interactive login. | |
| 29 | - | ||
| 30 | 19 | ## Options | |
| 31 | 20 | ||
| 32 | 21 | | Option | Type | Default | Description | | |
| @@ -35,12 +24,11 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||
| 35 | 24 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | |
| 36 | 25 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | |
| 37 | 26 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | |
| 38 | - | | `username` | string | - | Username for password auth (optional) | | |
| 39 | - | | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) | | |
| 27 | + | | `access_token_ttl` | string | `"15m"` | Lifetime of ephemeral access tokens issued by `/auth/token` | | |
| 40 | 28 | ||
| 41 | 29 | ## Secrets Backends | |
| 42 | 30 | ||
| 43 | - | `token_secret` and `password_hash` are read through the configured backend: | |
| 31 | + | `token_secret` is read through the configured backend: | |
| 44 | 32 | ||
| 45 | 33 | | Backend | Description | | |
| 46 | 34 | | --------- | ------------- | | |
| @@ -52,7 +40,7 @@ See [Secret Providers](./secrets.md) for setup details. | |||
| 52 | 40 | ||
| 53 | 41 | ## Environment Variable | |
| 54 | 42 | ||
| 55 | - | `GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don’t set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`). | |
| 43 | + | `GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don't set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`). | |
| 56 | 44 | ||
| 57 | 45 | ```bash | |
| 58 | 46 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | |
| @@ -61,8 +49,6 @@ gordon serve | |||
| 61 | 49 | ||
| 62 | 50 | ## Setup | |
| 63 | 51 | ||
| 64 | - | ### Token-only setup | |
| 65 | - | ||
| 66 | 52 | ```bash | |
| 67 | 53 | # Create token secret (random 32+ characters) | |
| 68 | 54 | pass insert gordon/auth/token_secret | |
| @@ -76,28 +62,9 @@ gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |||
| 76 | 62 | docker login -u ci-bot -p <token> registry.mydomain.com | |
| 77 | 63 | ``` | |
| 78 | 64 | ||
| 79 | - | ### Password + token setup | |
| 80 | - | ||
| 81 | - | ```bash | |
| 82 | - | # Generate bcrypt hash | |
| 83 | - | gordon auth password hash | |
| 84 | - | ||
| 85 | - | # Store hash and token secret | |
| 86 | - | pass insert gordon/auth/password_hash | |
| 87 | - | pass insert gordon/auth/token_secret | |
| 88 | - | ``` | |
| 89 | - | ||
| 90 | - | ```bash | |
| 91 | - | # Docker registry access | |
| 92 | - | docker login -u deploy -p <password> registry.mydomain.com | |
| 93 | - | ||
| 94 | - | # Remote CLI login (stores a token for you) | |
| 95 | - | gordon auth login --remote prod | |
| 96 | - | ``` | |
| 97 | - | ||
| 98 | 65 | ## Remote CLI Tokens | |
| 99 | 66 | ||
| 100 | - | If you already have a token (token-only servers), store it directly: | |
| 67 | + | Store a token for remote CLI access: | |
| 101 | 68 | ||
| 102 | 69 | ```bash | |
| 103 | 70 | gordon auth login --token <token> | |
| @@ -136,6 +103,23 @@ gordon auth token generate --subject builder --scopes push --expiry 0 | |||
| 136 | 103 | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | |
| 137 | 104 | ``` | |
| 138 | 105 | ||
| 106 | + | ## Access Token TTL | |
| 107 | + | ||
| 108 | + | The `access_token_ttl` setting controls the lifetime of ephemeral access tokens issued by the `/auth/token` endpoint. These short-lived tokens are used internally for registry operations and Admin API sessions. | |
| 109 | + | ||
| 110 | + | | Value | Meaning | | |
| 111 | + | |-------|---------| | |
| 112 | + | | `"15m"` | 15 minutes (default) | | |
| 113 | + | | `"30m"` | 30 minutes | | |
| 114 | + | | `"1h"` | 1 hour (maximum before store validation applies) | | |
| 115 | + | ||
| 116 | + | Tokens with a lifetime of 1 hour or less skip store validation for performance. Longer-lived tokens (generated via `gordon auth token generate`) are always validated against the token store. | |
| 117 | + | ||
| 118 | + | ```toml | |
| 119 | + | [auth] | |
| 120 | + | access_token_ttl = "30m" | |
| 121 | + | ``` | |
| 122 | + | ||
| 139 | 123 | ## Token Expiry | |
| 140 | 124 | ||
| 141 | 125 | Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`. | |
| @@ -4,29 +4,18 @@ Configure authentication for the Gordon registry and Admin API. | |||||
| 4 | 4 | ||||
| 5 | Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`). | 5 | Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`). | ||
| 6 | 6 | ||||
| 7 | - | ## Pick a Mode | 7 | + | ## Configuration |
| 8 | 8 | ||||
| 9 | - | ### Token-only (recommended for CI/CD) | 9 | + | Gordon uses token-only authentication. All registry and Admin API access is controlled through JWT tokens. |
| 10 | 10 | ||||
| 11 | ```toml | 11 | ```toml | ||
| 12 | [auth] | 12 | [auth] | ||
| 13 | secrets_backend = "pass" | 13 | secrets_backend = "pass" | ||
| 14 | token_secret = "gordon/auth/token_secret" | 14 | token_secret = "gordon/auth/token_secret" | ||
| 15 | token_expiry = "30d" | 15 | token_expiry = "30d" | ||
| 16 | + | access_token_ttl = "15m" | |||
| 16 | ``` | 17 | ``` | ||
| 17 | 18 | ||||
| 18 | - | ### Password + Token (interactive + CI/CD) | |||
| 19 | - | ||||
| 20 | - | ```toml | |||
| 21 | - | [auth] | |||
| 22 | - | secrets_backend = "pass" | |||
| 23 | - | username = "deploy" | |||
| 24 | - | password_hash = "gordon/auth/password_hash" | |||
| 25 | - | token_secret = "gordon/auth/token_secret" | |||
| 26 | - | ``` | |||
| 27 | - | ||||
| 28 | - | Token auth always works. Adding `username` + `password_hash` enables interactive login. | |||
| 29 | - | ||||
| 30 | ## Options | 19 | ## Options | ||
| 31 | 20 | ||||
| 32 | | Option | Type | Default | Description | | 21 | | Option | Type | Default | Description | | ||
| @@ -35,12 +24,11 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||||
| 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | 24 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | ||
| 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | 25 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | ||
| 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | 26 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | ||
| 38 | - | | `username` | string | - | Username for password auth (optional) | | 27 | + | | `access_token_ttl` | string | `"15m"` | Lifetime of ephemeral access tokens issued by `/auth/token` | |
| 39 | - | | `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) | | |||
| 40 | 28 | ||||
| 41 | ## Secrets Backends | 29 | ## Secrets Backends | ||
| 42 | 30 | ||||
| 43 | - | `token_secret` and `password_hash` are read through the configured backend: | 31 | + | `token_secret` is read through the configured backend: |
| 44 | 32 | ||||
| 45 | | Backend | Description | | 33 | | Backend | Description | | ||
| 46 | | --------- | ------------- | | 34 | | --------- | ------------- | | ||
| @@ -52,7 +40,7 @@ See [Secret Providers](./secrets.md) for setup details. | |||||
| 52 | 40 | ||||
| 53 | ## Environment Variable | 41 | ## Environment Variable | ||
| 54 | 42 | ||||
| 55 | - | `GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don’t set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`). | 43 | + | `GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don't set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`). |
| 56 | 44 | ||||
| 57 | ```bash | 45 | ```bash | ||
| 58 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | 46 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | ||
| @@ -61,8 +49,6 @@ gordon serve | |||||
| 61 | 49 | ||||
| 62 | ## Setup | 50 | ## Setup | ||
| 63 | 51 | ||||
| 64 | - | ### Token-only setup | |||
| 65 | - | ||||
| 66 | ```bash | 52 | ```bash | ||
| 67 | # Create token secret (random 32+ characters) | 53 | # Create token secret (random 32+ characters) | ||
| 68 | pass insert gordon/auth/token_secret | 54 | pass insert gordon/auth/token_secret | ||
| @@ -76,28 +62,9 @@ gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0 | |||||
| 76 | docker login -u ci-bot -p <token> registry.mydomain.com | 62 | docker login -u ci-bot -p <token> registry.mydomain.com | ||
| 77 | ``` | 63 | ``` | ||
| 78 | 64 | ||||
| 79 | - | ### Password + token setup | |||
| 80 | - | ||||
| 81 | - | ```bash | |||
| 82 | - | # Generate bcrypt hash | |||
| 83 | - | gordon auth password hash | |||
| 84 | - | ||||
| 85 | - | # Store hash and token secret | |||
| 86 | - | pass insert gordon/auth/password_hash | |||
| 87 | - | pass insert gordon/auth/token_secret | |||
| 88 | - | ``` | |||
| 89 | - | ||||
| 90 | - | ```bash | |||
| 91 | - | # Docker registry access | |||
| 92 | - | docker login -u deploy -p <password> registry.mydomain.com | |||
| 93 | - | ||||
| 94 | - | # Remote CLI login (stores a token for you) | |||
| 95 | - | gordon auth login --remote prod | |||
| 96 | - | ``` | |||
| 97 | - | ||||
| 98 | ## Remote CLI Tokens | 65 | ## Remote CLI Tokens | ||
| 99 | 66 | ||||
| 100 | - | If you already have a token (token-only servers), store it directly: | 67 | + | Store a token for remote CLI access: |
| 101 | 68 | ||||
| 102 | ```bash | 69 | ```bash | ||
| 103 | gordon auth login --token <token> | 70 | gordon auth login --token <token> | ||
| @@ -136,6 +103,23 @@ gordon auth token generate --subject builder --scopes push --expiry 0 | |||||
| 136 | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | 103 | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | ||
| 137 | ``` | 104 | ``` | ||
| 138 | 105 | ||||
| 106 | + | ## Access Token TTL | |||
| 107 | + | ||||
| 108 | + | The `access_token_ttl` setting controls the lifetime of ephemeral access tokens issued by the `/auth/token` endpoint. These short-lived tokens are used internally for registry operations and Admin API sessions. | |||
| 109 | + | ||||
| 110 | + | | Value | Meaning | | |||
| 111 | + | |-------|---------| | |||
| 112 | + | | `"15m"` | 15 minutes (default) | | |||
| 113 | + | | `"30m"` | 30 minutes | | |||
| 114 | + | | `"1h"` | 1 hour (maximum before store validation applies) | | |||
| 115 | + | ||||
| 116 | + | Tokens with a lifetime of 1 hour or less skip store validation for performance. Longer-lived tokens (generated via `gordon auth token generate`) are always validated against the token store. | |||
| 117 | + | ||||
| 118 | + | ```toml | |||
| 119 | + | [auth] | |||
| 120 | + | access_token_ttl = "30m" | |||
| 121 | + | ``` | |||
| 122 | + | ||||
| 139 | ## Token Expiry | 123 | ## Token Expiry | ||
| 140 | 124 | ||||
| 141 | Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`. | 125 | Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`. | ||
M docs/config/reference.md +2 -3
| @@ -28,9 +28,7 @@ enabled = true # Enable registry authentication (d | |||
| 28 | 28 | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | |
| 29 | 29 | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED) | |
| 30 | 30 | token_expiry = "720h" # Token expiry duration (720h = 30 days) | |
| 31 | - | # Optional: enable password authentication (for interactive login) | |
| 32 | - | # username = "" # Username for password auth | |
| 33 | - | # password_hash = "" # Path in secrets backend to bcrypt hash | |
| 31 | + | access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m) | |
| 34 | 32 | ||
| 35 | 33 | # ============================================================================= | |
| 36 | 34 | # API (applies to both Registry and Admin endpoints) | |
| @@ -182,6 +180,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 182 | 180 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 183 | 181 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 184 | 182 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| 183 | + | | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime | | |
| 185 | 184 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | |
| 186 | 185 | | `api.rate_limit.global_rps` | `500` | Global requests/second | | |
| 187 | 186 | | `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second | | |
| @@ -28,9 +28,7 @@ enabled = true # Enable registry authentication (d | |||||
| 28 | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | 28 | secrets_backend = "unsafe" # "pass", "sops", or "unsafe" | ||
| 29 | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED) | 29 | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED) | ||
| 30 | token_expiry = "720h" # Token expiry duration (720h = 30 days) | 30 | token_expiry = "720h" # Token expiry duration (720h = 30 days) | ||
| 31 | - | # Optional: enable password authentication (for interactive login) | 31 | + | access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m) |
| 32 | - | # username = "" # Username for password auth | |||
| 33 | - | # password_hash = "" # Path in secrets backend to bcrypt hash | |||
| 34 | 32 | ||||
| 35 | # ============================================================================= | 33 | # ============================================================================= | ||
| 36 | # API (applies to both Registry and Admin endpoints) | 34 | # API (applies to both Registry and Admin endpoints) | ||
| @@ -182,6 +180,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 182 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | 180 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | ||
| 183 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 181 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 184 | | `auth.token_expiry` | `"720h"` | 30 days | | 182 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
| 183 | + | | `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime | | |||
| 185 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | 184 | | `api.rate_limit.enabled` | `true` | Enable rate limiting | | ||
| 186 | | `api.rate_limit.global_rps` | `500` | Global requests/second | | 185 | | `api.rate_limit.global_rps` | `500` | Global requests/second | | ||
| 187 | | `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second | | 186 | | `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second | | ||
M docs/getting-started.md +1 -1
| @@ -132,7 +132,7 @@ gordon auth token generate --subject deploy --scopes push,pull --expiry 0 | |||
| 132 | 132 | ||
| 133 | 133 | `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token. | |
| 134 | 134 | ||
| 135 | - | Save this token securely - you'll use it with `gordon --remote` (or a saved remote), and it should be limited to remote deploy use. | |
| 135 | + | Save this token securely -- you will use it to authenticate with your Gordon server from CI/CD pipelines and remote CLI sessions. | |
| 136 | 136 | ||
| 137 | 137 | ## 8. Deploy Your First App | |
| 138 | 138 | ||
| @@ -132,7 +132,7 @@ gordon auth token generate --subject deploy --scopes push,pull --expiry 0 | |||||
| 132 | 132 | ||||
| 133 | `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token. | 133 | `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token. | ||
| 134 | 134 | ||||
| 135 | - | Save this token securely - you'll use it with `gordon --remote` (or a saved remote), and it should be limited to remote deploy use. | 135 | + | Save this token securely -- you will use it to authenticate with your Gordon server from CI/CD pipelines and remote CLI sessions. |
| 136 | 136 | ||||
| 137 | ## 8. Deploy Your First App | 137 | ## 8. Deploy Your First App | ||
| 138 | 138 | ||||
M docs/upgrading.md +103 -0
| @@ -2,6 +2,109 @@ | |||
| 2 | 2 | ||
| 3 | 3 | This guide covers breaking changes and migration steps between major versions. | |
| 4 | 4 | ||
| 5 | + | ## v2.16.0 to v2.30.0 | |
| 6 | + | ||
| 7 | + | ### Breaking: Password Authentication Removed | |
| 8 | + | ||
| 9 | + | Gordon v2.30.0 removes password-based authentication entirely. Only token-based authentication is supported. | |
| 10 | + | ||
| 11 | + | **What changed:** | |
| 12 | + | ||
| 13 | + | - `auth.type = "password"` is no longer accepted | |
| 14 | + | - `auth.password` and `auth.password_hash` config fields are removed | |
| 15 | + | - The `gordon auth password hash` CLI command is removed | |
| 16 | + | - The `gordon auth login` command no longer accepts `--username` or `--password` flags | |
| 17 | + | - The `/auth/password` endpoint now returns `410 Gone` | |
| 18 | + | ||
| 19 | + | **New features:** | |
| 20 | + | ||
| 21 | + | - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`) | |
| 22 | + | - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations | |
| 23 | + | ||
| 24 | + | **Migration steps:** | |
| 25 | + | ||
| 26 | + | 1. **Before upgrading**, generate a token on your current Gordon instance: | |
| 27 | + | ||
| 28 | + | ```bash | |
| 29 | + | gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0 | |
| 30 | + | ``` | |
| 31 | + | ||
| 32 | + | Save this token securely. You will need it after upgrading. Admin scopes (`admin:*:*`) are only available after upgrading to v2.30.0 — regenerate your token with admin scopes after the upgrade if needed. | |
| 33 | + | ||
| 34 | + | 2. **Update remotes** to use the generated token: | |
| 35 | + | ||
| 36 | + | ```bash | |
| 37 | + | gordon auth login --token <token> | |
| 38 | + | # or | |
| 39 | + | gordon remotes set-token prod <token> | |
| 40 | + | ``` | |
| 41 | + | ||
| 42 | + | 3. **Update your config** to remove password fields: | |
| 43 | + | ||
| 44 | + | **Before (v2.16.0):** | |
| 45 | + | ||
| 46 | + | ```toml | |
| 47 | + | [auth] | |
| 48 | + | enabled = true | |
| 49 | + | secrets_backend = "pass" | |
| 50 | + | username = "deploy" | |
| 51 | + | password_hash = "gordon/auth/password_hash" | |
| 52 | + | token_secret = "gordon/auth/token_secret" | |
| 53 | + | ``` | |
| 54 | + | ||
| 55 | + | **After (v2.30.0):** | |
| 56 | + | ||
| 57 | + | ```toml | |
| 58 | + | [auth] | |
| 59 | + | enabled = true | |
| 60 | + | secrets_backend = "pass" | |
| 61 | + | token_secret = "gordon/auth/token_secret" | |
| 62 | + | access_token_ttl = "15m" | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | 4. **Update CI/CD pipelines** to use `GORDON_TOKEN` environment variable for authentication. See the [deployment guides](./deployment/index.md). | |
| 66 | + | ||
| 67 | + | 5. **Upgrade the binary** and restart: | |
| 68 | + | ||
| 69 | + | ```bash | |
| 70 | + | curl -fsSL https://gordon.bnema.dev/install | bash | |
| 71 | + | systemctl --user restart gordon | |
| 72 | + | ``` | |
| 73 | + | ||
| 74 | + | 6. **Verify** the server starts without errors: | |
| 75 | + | ||
| 76 | + | ```bash | |
| 77 | + | journalctl --user -u gordon -f | |
| 78 | + | ``` | |
| 79 | + | ||
| 80 | + | ### New: Configurable Access Token TTL | |
| 81 | + | ||
| 82 | + | Ephemeral access tokens issued by `/auth/token` now have a configurable lifetime via `auth.access_token_ttl` (default `"15m"`, maximum `"1h"`). | |
| 83 | + | ||
| 84 | + | Tokens with a lifetime at or below `MaxAccessTokenLifetime` (1 hour) are treated as ephemeral: they skip token store validation for performance, which means they **cannot be individually revoked**. They become invalid only when they naturally expire. Shortening `auth.access_token_ttl` only reduces the exposure window; it does not enable per-token revocation. If you need explicit revocation, use a stored long-lived token instead of `/auth/token`. | |
| 85 | + | ||
| 86 | + | ```toml | |
| 87 | + | [auth] | |
| 88 | + | access_token_ttl = "30m" | |
| 89 | + | ``` | |
| 90 | + | ||
| 91 | + | ### New: Admin Scopes | |
| 92 | + | ||
| 93 | + | Tokens can now include fine-grained admin scopes for remote CLI operations: | |
| 94 | + | ||
| 95 | + | ```bash | |
| 96 | + | # Full admin access | |
| 97 | + | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | |
| 98 | + | ||
| 99 | + | # Read-only monitoring | |
| 100 | + | gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d | |
| 101 | + | ||
| 102 | + | # CI deploy with route read + config write | |
| 103 | + | gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0 | |
| 104 | + | ``` | |
| 105 | + | ||
| 106 | + | See [Token Scopes](./config/auth.md#token-scopes) for the full list. | |
| 107 | + | ||
| 5 | 108 | ## v2.6.0 to v2.7.0 | |
| 6 | 109 | ||
| 7 | 110 | ### Breaking: Token Secret Required | |
| @@ -2,6 +2,109 @@ | |||||
| 2 | 2 | ||||
| 3 | This guide covers breaking changes and migration steps between major versions. | 3 | This guide covers breaking changes and migration steps between major versions. | ||
| 4 | 4 | ||||
| 5 | + | ## v2.16.0 to v2.30.0 | |||
| 6 | + | ||||
| 7 | + | ### Breaking: Password Authentication Removed | |||
| 8 | + | ||||
| 9 | + | Gordon v2.30.0 removes password-based authentication entirely. Only token-based authentication is supported. | |||
| 10 | + | ||||
| 11 | + | **What changed:** | |||
| 12 | + | ||||
| 13 | + | - `auth.type = "password"` is no longer accepted | |||
| 14 | + | - `auth.password` and `auth.password_hash` config fields are removed | |||
| 15 | + | - The `gordon auth password hash` CLI command is removed | |||
| 16 | + | - The `gordon auth login` command no longer accepts `--username` or `--password` flags | |||
| 17 | + | - The `/auth/password` endpoint now returns `410 Gone` | |||
| 18 | + | ||||
| 19 | + | **New features:** | |||
| 20 | + | ||||
| 21 | + | - `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`) | |||
| 22 | + | - Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations | |||
| 23 | + | ||||
| 24 | + | **Migration steps:** | |||
| 25 | + | ||||
| 26 | + | 1. **Before upgrading**, generate a token on your current Gordon instance: | |||
| 27 | + | ||||
| 28 | + | ```bash | |||
| 29 | + | gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0 | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | Save this token securely. You will need it after upgrading. Admin scopes (`admin:*:*`) are only available after upgrading to v2.30.0 — regenerate your token with admin scopes after the upgrade if needed. | |||
| 33 | + | ||||
| 34 | + | 2. **Update remotes** to use the generated token: | |||
| 35 | + | ||||
| 36 | + | ```bash | |||
| 37 | + | gordon auth login --token <token> | |||
| 38 | + | # or | |||
| 39 | + | gordon remotes set-token prod <token> | |||
| 40 | + | ``` | |||
| 41 | + | ||||
| 42 | + | 3. **Update your config** to remove password fields: | |||
| 43 | + | ||||
| 44 | + | **Before (v2.16.0):** | |||
| 45 | + | ||||
| 46 | + | ```toml | |||
| 47 | + | [auth] | |||
| 48 | + | enabled = true | |||
| 49 | + | secrets_backend = "pass" | |||
| 50 | + | username = "deploy" | |||
| 51 | + | password_hash = "gordon/auth/password_hash" | |||
| 52 | + | token_secret = "gordon/auth/token_secret" | |||
| 53 | + | ``` | |||
| 54 | + | ||||
| 55 | + | **After (v2.30.0):** | |||
| 56 | + | ||||
| 57 | + | ```toml | |||
| 58 | + | [auth] | |||
| 59 | + | enabled = true | |||
| 60 | + | secrets_backend = "pass" | |||
| 61 | + | token_secret = "gordon/auth/token_secret" | |||
| 62 | + | access_token_ttl = "15m" | |||
| 63 | + | ``` | |||
| 64 | + | ||||
| 65 | + | 4. **Update CI/CD pipelines** to use `GORDON_TOKEN` environment variable for authentication. See the [deployment guides](./deployment/index.md). | |||
| 66 | + | ||||
| 67 | + | 5. **Upgrade the binary** and restart: | |||
| 68 | + | ||||
| 69 | + | ```bash | |||
| 70 | + | curl -fsSL https://gordon.bnema.dev/install | bash | |||
| 71 | + | systemctl --user restart gordon | |||
| 72 | + | ``` | |||
| 73 | + | ||||
| 74 | + | 6. **Verify** the server starts without errors: | |||
| 75 | + | ||||
| 76 | + | ```bash | |||
| 77 | + | journalctl --user -u gordon -f | |||
| 78 | + | ``` | |||
| 79 | + | ||||
| 80 | + | ### New: Configurable Access Token TTL | |||
| 81 | + | ||||
| 82 | + | Ephemeral access tokens issued by `/auth/token` now have a configurable lifetime via `auth.access_token_ttl` (default `"15m"`, maximum `"1h"`). | |||
| 83 | + | ||||
| 84 | + | Tokens with a lifetime at or below `MaxAccessTokenLifetime` (1 hour) are treated as ephemeral: they skip token store validation for performance, which means they **cannot be individually revoked**. They become invalid only when they naturally expire. Shortening `auth.access_token_ttl` only reduces the exposure window; it does not enable per-token revocation. If you need explicit revocation, use a stored long-lived token instead of `/auth/token`. | |||
| 85 | + | ||||
| 86 | + | ```toml | |||
| 87 | + | [auth] | |||
| 88 | + | access_token_ttl = "30m" | |||
| 89 | + | ``` | |||
| 90 | + | ||||
| 91 | + | ### New: Admin Scopes | |||
| 92 | + | ||||
| 93 | + | Tokens can now include fine-grained admin scopes for remote CLI operations: | |||
| 94 | + | ||||
| 95 | + | ```bash | |||
| 96 | + | # Full admin access | |||
| 97 | + | gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0 | |||
| 98 | + | ||||
| 99 | + | # Read-only monitoring | |||
| 100 | + | gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d | |||
| 101 | + | ||||
| 102 | + | # CI deploy with route read + config write | |||
| 103 | + | gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0 | |||
| 104 | + | ``` | |||
| 105 | + | ||||
| 106 | + | See [Token Scopes](./config/auth.md#token-scopes) for the full list. | |||
| 107 | + | ||||
| 5 | ## v2.6.0 to v2.7.0 | 108 | ## v2.6.0 to v2.7.0 | ||
| 6 | 109 | ||||
| 7 | ### Breaking: Token Secret Required | 110 | ### Breaking: Token Secret Required | ||
M go.mod +0 -1
| @@ -22,7 +22,6 @@ require ( | |||
| 22 | 22 | go.opentelemetry.io/otel/sdk v1.42.0 | |
| 23 | 23 | go.opentelemetry.io/otel/sdk/log v0.18.0 | |
| 24 | 24 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | |
| 25 | - | golang.org/x/crypto v0.49.0 | |
| 26 | 25 | golang.org/x/mod v0.34.0 | |
| 27 | 26 | golang.org/x/term v0.41.0 | |
| 28 | 27 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| @@ -22,7 +22,6 @@ require ( | |||||
| 22 | go.opentelemetry.io/otel/sdk v1.42.0 | 22 | go.opentelemetry.io/otel/sdk v1.42.0 | ||
| 23 | go.opentelemetry.io/otel/sdk/log v0.18.0 | 23 | go.opentelemetry.io/otel/sdk/log v0.18.0 | ||
| 24 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | 24 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | ||
| 25 | - | golang.org/x/crypto v0.49.0 | |||
| 26 | golang.org/x/mod v0.34.0 | 25 | golang.org/x/mod v0.34.0 | ||
| 27 | golang.org/x/term v0.41.0 | 26 | golang.org/x/term v0.41.0 | ||
| 28 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | 27 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||
M go.sum +0 -2 generated file: collapsed
M internal/adapters/in/cli/auth.go +1 -78
| @@ -44,7 +44,6 @@ func newAuthCmd() *cobra.Command { | |||
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | cmd.AddCommand(newAuthTokenCmd()) | |
| 47 | - | cmd.AddCommand(newAuthPasswordCmd()) | |
| 48 | 47 | cmd.AddCommand(newAuthInternalCmd()) | |
| 49 | 48 | cmd.AddCommand(newAuthLoginCmd()) | |
| 50 | 49 | cmd.AddCommand(newAuthStatusCmd()) | |
| @@ -191,19 +190,6 @@ Examples: | |||
| 191 | 190 | return cmd | |
| 192 | 191 | } | |
| 193 | 192 | ||
| 194 | - | // newAuthPasswordCmd creates the password subcommand group. | |
| 195 | - | func newAuthPasswordCmd() *cobra.Command { | |
| 196 | - | cmd := &cobra.Command{ | |
| 197 | - | Use: "password", | |
| 198 | - | Short: "Password utilities", | |
| 199 | - | Long: `Utilities for managing password authentication.`, | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | cmd.AddCommand(newPasswordHashCmd()) | |
| 203 | - | ||
| 204 | - | return cmd | |
| 205 | - | } | |
| 206 | - | ||
| 207 | 193 | // newAuthInternalCmd creates the internal credentials command. | |
| 208 | 194 | func newAuthInternalCmd() *cobra.Command { | |
| 209 | 195 | return &cobra.Command{ | |
| @@ -420,24 +406,6 @@ func runShowInternalAuth() error { | |||
| 420 | 406 | return nil | |
| 421 | 407 | } | |
| 422 | 408 | ||
| 423 | - | // newPasswordHashCmd creates the password hash command. | |
| 424 | - | func newPasswordHashCmd() *cobra.Command { | |
| 425 | - | return &cobra.Command{ | |
| 426 | - | Use: "hash", | |
| 427 | - | Short: "Generate a bcrypt hash for a password", | |
| 428 | - | Long: `Interactively prompt for a password and output its bcrypt hash. | |
| 429 | - | ||
| 430 | - | The hash can be stored in your secrets backend and referenced in the config: | |
| 431 | - | ||
| 432 | - | [auth] | |
| 433 | - | type = "password" | |
| 434 | - | password_hash = "github.com/bnema/gordon/auth/password_hash"`, | |
| 435 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 436 | - | return runPasswordHash() | |
| 437 | - | }, | |
| 438 | - | } | |
| 439 | - | } | |
| 440 | - | ||
| 441 | 409 | // runTokenGenerate generates a new authentication token. | |
| 442 | 410 | // parseAndConvertScopes parses a comma-separated scope string and converts | |
| 443 | 411 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | |
| @@ -646,50 +614,6 @@ func runTokenRevokeAll(configPath string) error { | |||
| 646 | 614 | return nil | |
| 647 | 615 | } | |
| 648 | 616 | ||
| 649 | - | // runPasswordHash generates a bcrypt hash for a password. | |
| 650 | - | func runPasswordHash() error { | |
| 651 | - | fmt.Print("Enter password: ") | |
| 652 | - | ||
| 653 | - | // Read password without echo (use os.Stdin.Fd() for better compatibility) | |
| 654 | - | fd, err := stdinFD() | |
| 655 | - | if err != nil { | |
| 656 | - | return fmt.Errorf("failed to get stdin fd: %w", err) | |
| 657 | - | } | |
| 658 | - | passwordBytes, err := term.ReadPassword(fd) | |
| 659 | - | if err != nil { | |
| 660 | - | // Fallback for non-terminal input | |
| 661 | - | reader := bufio.NewReader(os.Stdin) | |
| 662 | - | password, err := reader.ReadString('\n') | |
| 663 | - | if err != nil { | |
| 664 | - | return fmt.Errorf("failed to read password: %w", err) | |
| 665 | - | } | |
| 666 | - | passwordBytes = []byte(strings.TrimSpace(password)) | |
| 667 | - | } | |
| 668 | - | fmt.Println() | |
| 669 | - | ||
| 670 | - | if len(passwordBytes) == 0 { | |
| 671 | - | return fmt.Errorf("password cannot be empty") | |
| 672 | - | } | |
| 673 | - | ||
| 674 | - | // Create a temporary auth service to generate the hash | |
| 675 | - | authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default()) | |
| 676 | - | hash, err := authSvc.GeneratePasswordHash(string(passwordBytes)) | |
| 677 | - | if err != nil { | |
| 678 | - | return fmt.Errorf("failed to generate hash: %w", err) | |
| 679 | - | } | |
| 680 | - | ||
| 681 | - | fmt.Println() | |
| 682 | - | fmt.Println("Bcrypt hash (store in your secrets backend):") | |
| 683 | - | fmt.Println(hash) | |
| 684 | - | fmt.Println() | |
| 685 | - | fmt.Println("Then reference the path in your config:") | |
| 686 | - | fmt.Println(" [auth]") | |
| 687 | - | fmt.Println(" type = \"password\"") | |
| 688 | - | fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"") | |
| 689 | - | ||
| 690 | - | return nil | |
| 691 | - | } | |
| 692 | - | ||
| 693 | 617 | // cliConfig holds the configuration needed for CLI commands. | |
| 694 | 618 | type cliConfig struct { | |
| 695 | 619 | Backend domain.SecretsBackend | |
| @@ -748,8 +672,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||
| 748 | 672 | cfg.TokenSecret = []byte(tokenSecretPath) | |
| 749 | 673 | } | |
| 750 | 674 | } else { | |
| 751 | - | // Generate a default secret for CLI token generation | |
| 752 | - | cfg.TokenSecret = []byte("gordon-cli-default-secret") | |
| 675 | + | return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret") | |
| 753 | 676 | } | |
| 754 | 677 | ||
| 755 | 678 | return cfg, nil | |
| @@ -44,7 +44,6 @@ func newAuthCmd() *cobra.Command { | |||||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | cmd.AddCommand(newAuthTokenCmd()) | 46 | cmd.AddCommand(newAuthTokenCmd()) | ||
| 47 | - | cmd.AddCommand(newAuthPasswordCmd()) | |||
| 48 | cmd.AddCommand(newAuthInternalCmd()) | 47 | cmd.AddCommand(newAuthInternalCmd()) | ||
| 49 | cmd.AddCommand(newAuthLoginCmd()) | 48 | cmd.AddCommand(newAuthLoginCmd()) | ||
| 50 | cmd.AddCommand(newAuthStatusCmd()) | 49 | cmd.AddCommand(newAuthStatusCmd()) | ||
| @@ -191,19 +190,6 @@ Examples: | |||||
| 191 | return cmd | 190 | return cmd | ||
| 192 | } | 191 | } | ||
| 193 | 192 | ||||
| 194 | - | // newAuthPasswordCmd creates the password subcommand group. | |||
| 195 | - | func newAuthPasswordCmd() *cobra.Command { | |||
| 196 | - | cmd := &cobra.Command{ | |||
| 197 | - | Use: "password", | |||
| 198 | - | Short: "Password utilities", | |||
| 199 | - | Long: `Utilities for managing password authentication.`, | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | cmd.AddCommand(newPasswordHashCmd()) | |||
| 203 | - | ||||
| 204 | - | return cmd | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | // newAuthInternalCmd creates the internal credentials command. | 193 | // newAuthInternalCmd creates the internal credentials command. | ||
| 208 | func newAuthInternalCmd() *cobra.Command { | 194 | func newAuthInternalCmd() *cobra.Command { | ||
| 209 | return &cobra.Command{ | 195 | return &cobra.Command{ | ||
| @@ -420,24 +406,6 @@ func runShowInternalAuth() error { | |||||
| 420 | return nil | 406 | return nil | ||
| 421 | } | 407 | } | ||
| 422 | 408 | ||||
| 423 | - | // newPasswordHashCmd creates the password hash command. | |||
| 424 | - | func newPasswordHashCmd() *cobra.Command { | |||
| 425 | - | return &cobra.Command{ | |||
| 426 | - | Use: "hash", | |||
| 427 | - | Short: "Generate a bcrypt hash for a password", | |||
| 428 | - | Long: `Interactively prompt for a password and output its bcrypt hash. | |||
| 429 | - | ||||
| 430 | - | The hash can be stored in your secrets backend and referenced in the config: | |||
| 431 | - | ||||
| 432 | - | [auth] | |||
| 433 | - | type = "password" | |||
| 434 | - | password_hash = "github.com/bnema/gordon/auth/password_hash"`, | |||
| 435 | - | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 436 | - | return runPasswordHash() | |||
| 437 | - | }, | |||
| 438 | - | } | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | // runTokenGenerate generates a new authentication token. | 409 | // runTokenGenerate generates a new authentication token. | ||
| 442 | // parseAndConvertScopes parses a comma-separated scope string and converts | 410 | // parseAndConvertScopes parses a comma-separated scope string and converts | ||
| 443 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | 411 | // simple scopes (push, pull, *) to Docker v2 format (repository:*:action). | ||
| @@ -646,50 +614,6 @@ func runTokenRevokeAll(configPath string) error { | |||||
| 646 | return nil | 614 | return nil | ||
| 647 | } | 615 | } | ||
| 648 | 616 | ||||
| 649 | - | // runPasswordHash generates a bcrypt hash for a password. | |||
| 650 | - | func runPasswordHash() error { | |||
| 651 | - | fmt.Print("Enter password: ") | |||
| 652 | - | ||||
| 653 | - | // Read password without echo (use os.Stdin.Fd() for better compatibility) | |||
| 654 | - | fd, err := stdinFD() | |||
| 655 | - | if err != nil { | |||
| 656 | - | return fmt.Errorf("failed to get stdin fd: %w", err) | |||
| 657 | - | } | |||
| 658 | - | passwordBytes, err := term.ReadPassword(fd) | |||
| 659 | - | if err != nil { | |||
| 660 | - | // Fallback for non-terminal input | |||
| 661 | - | reader := bufio.NewReader(os.Stdin) | |||
| 662 | - | password, err := reader.ReadString('\n') | |||
| 663 | - | if err != nil { | |||
| 664 | - | return fmt.Errorf("failed to read password: %w", err) | |||
| 665 | - | } | |||
| 666 | - | passwordBytes = []byte(strings.TrimSpace(password)) | |||
| 667 | - | } | |||
| 668 | - | fmt.Println() | |||
| 669 | - | ||||
| 670 | - | if len(passwordBytes) == 0 { | |||
| 671 | - | return fmt.Errorf("password cannot be empty") | |||
| 672 | - | } | |||
| 673 | - | ||||
| 674 | - | // Create a temporary auth service to generate the hash | |||
| 675 | - | authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default()) | |||
| 676 | - | hash, err := authSvc.GeneratePasswordHash(string(passwordBytes)) | |||
| 677 | - | if err != nil { | |||
| 678 | - | return fmt.Errorf("failed to generate hash: %w", err) | |||
| 679 | - | } | |||
| 680 | - | ||||
| 681 | - | fmt.Println() | |||
| 682 | - | fmt.Println("Bcrypt hash (store in your secrets backend):") | |||
| 683 | - | fmt.Println(hash) | |||
| 684 | - | fmt.Println() | |||
| 685 | - | fmt.Println("Then reference the path in your config:") | |||
| 686 | - | fmt.Println(" [auth]") | |||
| 687 | - | fmt.Println(" type = \"password\"") | |||
| 688 | - | fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"") | |||
| 689 | - | ||||
| 690 | - | return nil | |||
| 691 | - | } | |||
| 692 | - | ||||
| 693 | // cliConfig holds the configuration needed for CLI commands. | 617 | // cliConfig holds the configuration needed for CLI commands. | ||
| 694 | type cliConfig struct { | 618 | type cliConfig struct { | ||
| 695 | Backend domain.SecretsBackend | 619 | Backend domain.SecretsBackend | ||
| @@ -748,8 +672,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) { | |||||
| 748 | cfg.TokenSecret = []byte(tokenSecretPath) | 672 | cfg.TokenSecret = []byte(tokenSecretPath) | ||
| 749 | } | 673 | } | ||
| 750 | } else { | 674 | } else { | ||
| 751 | - | // Generate a default secret for CLI token generation | 675 | + | return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret") |
| 752 | - | cfg.TokenSecret = []byte("gordon-cli-default-secret") | |||
| 753 | } | 676 | } | ||
| 754 | 677 | ||||
| 755 | return cfg, nil | 678 | return cfg, nil | ||
M internal/adapters/in/http/admin/handler.go +12 -1
| @@ -1264,6 +1264,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 1264 | 1264 | h.sendError(w, http.StatusBadRequest, "domain required in path") | |
| 1265 | 1265 | return | |
| 1266 | 1266 | } | |
| 1267 | + | if err := validation.ValidateDomainParam(deployDomain); err != nil { | |
| 1268 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 1269 | + | return | |
| 1270 | + | } | |
| 1267 | 1271 | ||
| 1268 | 1272 | // Get the route for this domain | |
| 1269 | 1273 | route, err := h.configSvc.GetRoute(ctx, deployDomain) | |
| @@ -1317,7 +1321,7 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str | |||
| 1317 | 1321 | h.sendError(w, http.StatusBadRequest, "domain required in path") | |
| 1318 | 1322 | return | |
| 1319 | 1323 | } | |
| 1320 | - | if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") { | |
| 1324 | + | if err := validation.ValidateDomainParam(restartDomain); err != nil { | |
| 1321 | 1325 | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 1322 | 1326 | return | |
| 1323 | 1327 | } | |
| @@ -1433,6 +1437,13 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||
| 1433 | 1437 | logDomain = "" | |
| 1434 | 1438 | } | |
| 1435 | 1439 | ||
| 1440 | + | if logDomain != "" { | |
| 1441 | + | if err := validation.ValidateDomainParam(logDomain); err != nil { | |
| 1442 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 1443 | + | return | |
| 1444 | + | } | |
| 1445 | + | } | |
| 1446 | + | ||
| 1436 | 1447 | if logDomain == "" { | |
| 1437 | 1448 | // Gordon process logs | |
| 1438 | 1449 | h.handleProcessLogs(w, r, lines, follow) | |
| @@ -1264,6 +1264,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||||
| 1264 | h.sendError(w, http.StatusBadRequest, "domain required in path") | 1264 | h.sendError(w, http.StatusBadRequest, "domain required in path") | ||
| 1265 | return | 1265 | return | ||
| 1266 | } | 1266 | } | ||
| 1267 | + | if err := validation.ValidateDomainParam(deployDomain); err != nil { | |||
| 1268 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 1269 | + | return | |||
| 1270 | + | } | |||
| 1267 | 1271 | ||||
| 1268 | // Get the route for this domain | 1272 | // Get the route for this domain | ||
| 1269 | route, err := h.configSvc.GetRoute(ctx, deployDomain) | 1273 | route, err := h.configSvc.GetRoute(ctx, deployDomain) | ||
| @@ -1317,7 +1321,7 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str | |||||
| 1317 | h.sendError(w, http.StatusBadRequest, "domain required in path") | 1321 | h.sendError(w, http.StatusBadRequest, "domain required in path") | ||
| 1318 | return | 1322 | return | ||
| 1319 | } | 1323 | } | ||
| 1320 | - | if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") { | 1324 | + | if err := validation.ValidateDomainParam(restartDomain); err != nil { |
| 1321 | h.sendError(w, http.StatusBadRequest, "invalid domain") | 1325 | h.sendError(w, http.StatusBadRequest, "invalid domain") | ||
| 1322 | return | 1326 | return | ||
| 1323 | } | 1327 | } | ||
| @@ -1433,6 +1437,13 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||||
| 1433 | logDomain = "" | 1437 | logDomain = "" | ||
| 1434 | } | 1438 | } | ||
| 1435 | 1439 | ||||
| 1440 | + | if logDomain != "" { | |||
| 1441 | + | if err := validation.ValidateDomainParam(logDomain); err != nil { | |||
| 1442 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 1443 | + | return | |||
| 1444 | + | } | |||
| 1445 | + | } | |||
| 1446 | + | ||||
| 1436 | if logDomain == "" { | 1447 | if logDomain == "" { | ||
| 1437 | // Gordon process logs | 1448 | // Gordon process logs | ||
| 1438 | h.handleProcessLogs(w, r, lines, follow) | 1449 | h.handleProcessLogs(w, r, lines, follow) | ||
M internal/adapters/in/http/admin/preview_handler.go +55 -37
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/zerowrap" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | "github.com/bnema/gordon/pkg/validation" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // previewService is a minimal interface for preview management used by the admin handler. | |
| @@ -78,18 +79,23 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa | |||
| 78 | 79 | return | |
| 79 | 80 | } | |
| 80 | 81 | ||
| 82 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 83 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | ||
| 81 | 87 | name := strings.TrimPrefix(path, "/preview/") | |
| 82 | 88 | if name == "" || name == "/preview" { | |
| 83 | 89 | h.sendError(w, http.StatusBadRequest, "preview name required in path") | |
| 84 | 90 | return | |
| 85 | 91 | } | |
| 92 | + | if err := validation.ValidateDomainParam(name); err != nil { | |
| 93 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 94 | + | return | |
| 95 | + | } | |
| 86 | 96 | ||
| 87 | 97 | switch r.Method { | |
| 88 | 98 | case http.MethodDelete: | |
| 89 | - | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 90 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 91 | - | return | |
| 92 | - | } | |
| 93 | 99 | ||
| 94 | 100 | if err := h.previewSvc.Delete(ctx, name); err != nil { | |
| 95 | 101 | log.Error().Err(err).Str("name", name).Msg("failed to delete preview") | |
| @@ -105,45 +111,57 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa | |||
| 105 | 111 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) | |
| 106 | 112 | ||
| 107 | 113 | case http.MethodPatch: | |
| 108 | - | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 109 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 110 | - | return | |
| 111 | - | } | |
| 114 | + | h.handlePreviewExtend(w, r, name) | |
| 112 | 115 | ||
| 113 | - | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 116 | + | default: | |
| 117 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 118 | + | } | |
| 119 | + | } | |
| 114 | 120 | ||
| 115 | - | var req previewExtendRequest | |
| 116 | - | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 117 | - | log.Warn().Err(err).Msg("invalid preview extend JSON") | |
| 118 | - | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 119 | - | return | |
| 120 | - | } | |
| 121 | + | func (h *Handler) handlePreviewExtend(w http.ResponseWriter, r *http.Request, name string) { | |
| 122 | + | ctx := r.Context() | |
| 123 | + | log := zerowrap.FromCtx(ctx) | |
| 121 | 124 | ||
| 122 | - | if req.TTL == "" { | |
| 123 | - | h.sendError(w, http.StatusBadRequest, "ttl is required") | |
| 124 | - | return | |
| 125 | - | } | |
| 125 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 126 | 126 | ||
| 127 | - | ttl, err := time.ParseDuration(req.TTL) | |
| 128 | - | if err != nil { | |
| 129 | - | h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m") | |
| 130 | - | return | |
| 131 | - | } | |
| 127 | + | var req previewExtendRequest | |
| 128 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 129 | + | log.Warn().Err(err).Msg("invalid preview extend JSON") | |
| 130 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 131 | + | return | |
| 132 | + | } | |
| 132 | 133 | ||
| 133 | - | if err := h.previewSvc.Extend(ctx, name, ttl); err != nil { | |
| 134 | - | log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL") | |
| 135 | - | if errors.Is(err, domain.ErrPreviewNotFound) { | |
| 136 | - | h.sendError(w, http.StatusNotFound, "preview not found") | |
| 137 | - | } else { | |
| 138 | - | h.sendError(w, http.StatusInternalServerError, "failed to extend preview") | |
| 139 | - | } | |
| 140 | - | return | |
| 141 | - | } | |
| 134 | + | if req.TTL == "" { | |
| 135 | + | h.sendError(w, http.StatusBadRequest, "ttl is required") | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | ttl, err := time.ParseDuration(req.TTL) | |
| 140 | + | if err != nil { | |
| 141 | + | h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m") | |
| 142 | + | return | |
| 143 | + | } | |
| 142 | 144 | ||
| 143 | - | log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API") | |
| 144 | - | h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL}) | |
| 145 | + | if ttl <= 0 { | |
| 146 | + | h.sendError(w, http.StatusBadRequest, "TTL must be positive") | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | const maxPreviewTTL = 30 * 24 * time.Hour // 30 days | |
| 150 | + | if ttl > maxPreviewTTL { | |
| 151 | + | h.sendError(w, http.StatusBadRequest, "TTL exceeds maximum of 30 days") | |
| 152 | + | return | |
| 153 | + | } | |
| 145 | 154 | ||
| 146 | - | default: | |
| 147 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 155 | + | if err := h.previewSvc.Extend(ctx, name, ttl); err != nil { | |
| 156 | + | log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL") | |
| 157 | + | if errors.Is(err, domain.ErrPreviewNotFound) { | |
| 158 | + | h.sendError(w, http.StatusNotFound, "preview not found") | |
| 159 | + | } else { | |
| 160 | + | h.sendError(w, http.StatusInternalServerError, "failed to extend preview") | |
| 161 | + | } | |
| 162 | + | return | |
| 148 | 163 | } | |
| 164 | + | ||
| 165 | + | log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API") | |
| 166 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL}) | |
| 149 | 167 | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/gordon/internal/domain" | 13 | "github.com/bnema/gordon/internal/domain" | ||
| 14 | + | "github.com/bnema/gordon/pkg/validation" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | // previewService is a minimal interface for preview management used by the admin handler. | 17 | // previewService is a minimal interface for preview management used by the admin handler. | ||
| @@ -78,18 +79,23 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa | |||||
| 78 | return | 79 | return | ||
| 79 | } | 80 | } | ||
| 80 | 81 | ||||
| 82 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 83 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | ||||
| 81 | name := strings.TrimPrefix(path, "/preview/") | 87 | name := strings.TrimPrefix(path, "/preview/") | ||
| 82 | if name == "" || name == "/preview" { | 88 | if name == "" || name == "/preview" { | ||
| 83 | h.sendError(w, http.StatusBadRequest, "preview name required in path") | 89 | h.sendError(w, http.StatusBadRequest, "preview name required in path") | ||
| 84 | return | 90 | return | ||
| 85 | } | 91 | } | ||
| 92 | + | if err := validation.ValidateDomainParam(name); err != nil { | |||
| 93 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 86 | 96 | ||||
| 87 | switch r.Method { | 97 | switch r.Method { | ||
| 88 | case http.MethodDelete: | 98 | case http.MethodDelete: | ||
| 89 | - | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 90 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 91 | - | return | |||
| 92 | - | } | |||
| 93 | 99 | ||||
| 94 | if err := h.previewSvc.Delete(ctx, name); err != nil { | 100 | if err := h.previewSvc.Delete(ctx, name); err != nil { | ||
| 95 | log.Error().Err(err).Str("name", name).Msg("failed to delete preview") | 101 | log.Error().Err(err).Str("name", name).Msg("failed to delete preview") | ||
| @@ -105,45 +111,57 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa | |||||
| 105 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) | 111 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) | ||
| 106 | 112 | ||||
| 107 | case http.MethodPatch: | 113 | case http.MethodPatch: | ||
| 108 | - | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | 114 | + | h.handlePreviewExtend(w, r, name) |
| 109 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 110 | - | return | |||
| 111 | - | } | |||
| 112 | 115 | ||||
| 113 | - | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | 116 | + | default: |
| 117 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 114 | 120 | ||||
| 115 | - | var req previewExtendRequest | 121 | + | func (h *Handler) handlePreviewExtend(w http.ResponseWriter, r *http.Request, name string) { |
| 116 | - | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | 122 | + | ctx := r.Context() |
| 117 | - | log.Warn().Err(err).Msg("invalid preview extend JSON") | 123 | + | log := zerowrap.FromCtx(ctx) |
| 118 | - | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 119 | - | return | |||
| 120 | - | } | |||
| 121 | 124 | ||||
| 122 | - | if req.TTL == "" { | 125 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) |
| 123 | - | h.sendError(w, http.StatusBadRequest, "ttl is required") | |||
| 124 | - | return | |||
| 125 | - | } | |||
| 126 | 126 | ||||
| 127 | - | ttl, err := time.ParseDuration(req.TTL) | 127 | + | var req previewExtendRequest |
| 128 | - | if err != nil { | 128 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 129 | - | h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m") | 129 | + | log.Warn().Err(err).Msg("invalid preview extend JSON") |
| 130 | - | return | 130 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") |
| 131 | - | } | 131 | + | return |
| 132 | + | } | |||
| 132 | 133 | ||||
| 133 | - | if err := h.previewSvc.Extend(ctx, name, ttl); err != nil { | 134 | + | if req.TTL == "" { |
| 134 | - | log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL") | 135 | + | h.sendError(w, http.StatusBadRequest, "ttl is required") |
| 135 | - | if errors.Is(err, domain.ErrPreviewNotFound) { | 136 | + | return |
| 136 | - | h.sendError(w, http.StatusNotFound, "preview not found") | 137 | + | } |
| 137 | - | } else { | 138 | + | |
| 138 | - | h.sendError(w, http.StatusInternalServerError, "failed to extend preview") | 139 | + | ttl, err := time.ParseDuration(req.TTL) |
| 139 | - | } | 140 | + | if err != nil { |
| 140 | - | return | 141 | + | h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m") |
| 141 | - | } | 142 | + | return |
| 143 | + | } | |||
| 142 | 144 | ||||
| 143 | - | log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API") | 145 | + | if ttl <= 0 { |
| 144 | - | h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL}) | 146 | + | h.sendError(w, http.StatusBadRequest, "TTL must be positive") |
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | const maxPreviewTTL = 30 * 24 * time.Hour // 30 days | |||
| 150 | + | if ttl > maxPreviewTTL { | |||
| 151 | + | h.sendError(w, http.StatusBadRequest, "TTL exceeds maximum of 30 days") | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 145 | 154 | ||||
| 146 | - | default: | 155 | + | if err := h.previewSvc.Extend(ctx, name, ttl); err != nil { |
| 147 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | 156 | + | log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL") |
| 157 | + | if errors.Is(err, domain.ErrPreviewNotFound) { | |||
| 158 | + | h.sendError(w, http.StatusNotFound, "preview not found") | |||
| 159 | + | } else { | |||
| 160 | + | h.sendError(w, http.StatusInternalServerError, "failed to extend preview") | |||
| 161 | + | } | |||
| 162 | + | return | |||
| 148 | } | 163 | } | ||
| 164 | + | ||||
| 165 | + | log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API") | |||
| 166 | + | h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL}) | |||
| 149 | } | 167 | } | ||
M internal/adapters/in/http/auth/handler.go +42 -163
| @@ -39,21 +39,6 @@ func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap. | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | // PasswordRequest represents the request body for POST /auth/password. | |
| 43 | - | type PasswordRequest struct { | |
| 44 | - | Username string `json:"username"` | |
| 45 | - | Password string `json:"password"` | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // PasswordResponse represents the response from POST /auth/password. | |
| 49 | - | type PasswordResponse struct { | |
| 50 | - | Token string `json:"token"` | |
| 51 | - | ExpiresIn int `json:"expires_in"` | |
| 52 | - | IssuedAt string `json:"issued_at"` | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | const passwordSessionTTL = 24 * time.Hour | |
| 56 | - | ||
| 57 | 42 | func setNoStoreHeaders(w http.ResponseWriter) { | |
| 58 | 43 | w.Header().Set("Cache-Control", "no-store") | |
| 59 | 44 | w.Header().Set("Pragma", "no-cache") | |
| @@ -76,104 +61,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 76 | 61 | } | |
| 77 | 62 | ||
| 78 | 63 | // handlePassword handles POST /auth/password requests. | |
| 79 | - | // Validates username/password and returns a daily session JWT. | |
| 80 | - | // Only works when auth type is "password" - returns error for "token" auth type. | |
| 64 | + | // Password authentication has been removed; this endpoint always returns Gone. | |
| 81 | 65 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |
| 82 | 66 | setNoStoreHeaders(w) | |
| 83 | - | ||
| 84 | - | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 85 | - | zerowrap.FieldLayer: "adapter", | |
| 86 | - | zerowrap.FieldAdapter: "http", | |
| 87 | - | zerowrap.FieldHandler: "auth", | |
| 88 | - | zerowrap.FieldMethod: r.Method, | |
| 89 | - | zerowrap.FieldPath: r.URL.Path, | |
| 90 | - | }) | |
| 91 | - | log := zerowrap.FromCtx(ctx) | |
| 92 | - | ||
| 93 | - | if r.Method != http.MethodPost { | |
| 94 | - | w.Header().Set("Content-Type", "application/json") | |
| 95 | - | w.WriteHeader(http.StatusMethodNotAllowed) | |
| 96 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) | |
| 97 | - | return | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // Check if auth is enabled | |
| 101 | - | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 102 | - | w.Header().Set("Content-Type", "application/json") | |
| 103 | - | w.WriteHeader(http.StatusServiceUnavailable) | |
| 104 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |
| 105 | - | return | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // Password endpoint only works with password auth type | |
| 109 | - | if h.authSvc.GetAuthType() != domain.AuthTypePassword { | |
| 110 | - | w.Header().Set("Content-Type", "application/json") | |
| 111 | - | w.WriteHeader(http.StatusBadRequest) | |
| 112 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |
| 113 | - | Error: "password authentication not configured, use token-based auth", | |
| 114 | - | }) | |
| 115 | - | return | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // Parse request body | |
| 119 | - | var req PasswordRequest | |
| 120 | - | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 121 | - | w.Header().Set("Content-Type", "application/json") | |
| 122 | - | w.WriteHeader(http.StatusBadRequest) | |
| 123 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"}) | |
| 124 | - | return | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | if req.Username == "" || req.Password == "" { | |
| 128 | - | w.Header().Set("Content-Type", "application/json") | |
| 129 | - | w.WriteHeader(http.StatusBadRequest) | |
| 130 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"}) | |
| 131 | - | return | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // Validate password | |
| 135 | - | if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) { | |
| 136 | - | log.Debug(). | |
| 137 | - | Str("username", req.Username). | |
| 138 | - | Msg("password authentication failed") | |
| 139 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`) | |
| 140 | - | w.Header().Set("Content-Type", "application/json") | |
| 141 | - | w.WriteHeader(http.StatusUnauthorized) | |
| 142 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"}) | |
| 143 | - | return | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // Generate daily session token with full access (push, pull, admin). | |
| 147 | - | // This enforces regular re-authentication similar to short-session UX. | |
| 148 | - | expiry := passwordSessionTTL | |
| 149 | - | scopes := []string{"push", "pull", "admin:*:*"} | |
| 150 | - | ||
| 151 | - | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |
| 152 | - | if err != nil { | |
| 153 | - | log.Error().Err(err).Msg("failed to generate token") | |
| 154 | - | w.Header().Set("Content-Type", "application/json") | |
| 155 | - | w.WriteHeader(http.StatusInternalServerError) | |
| 156 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"}) | |
| 157 | - | return | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | response := PasswordResponse{ | |
| 161 | - | Token: token, | |
| 162 | - | ExpiresIn: int(expiry.Seconds()), | |
| 163 | - | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 164 | - | } | |
| 165 | - | ||
| 166 | 67 | w.Header().Set("Content-Type", "application/json") | |
| 167 | - | w.WriteHeader(http.StatusOK) | |
| 168 | - | ||
| 169 | - | if err := json.NewEncoder(w).Encode(response); err != nil { | |
| 170 | - | log.Error().Err(err).Msg("failed to encode password response") | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | log.Debug(). | |
| 174 | - | Str("username", req.Username). | |
| 175 | - | Int("expires_in", response.ExpiresIn). | |
| 176 | - | Msg("long-lived token issued via password auth") | |
| 68 | + | w.WriteHeader(http.StatusGone) | |
| 69 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |
| 70 | + | Error: "password authentication has been removed, use token-based auth", | |
| 71 | + | }) | |
| 177 | 72 | } | |
| 178 | 73 | ||
| 179 | 74 | // handleToken handles GET /auth/token requests. | |
| @@ -240,8 +135,9 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 240 | 135 | } | |
| 241 | 136 | } | |
| 242 | 137 | ||
| 243 | - | // Generate a short-lived access token (5 minutes) - not stored | |
| 244 | - | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | |
| 138 | + | // Generate a short-lived access token with configurable TTL - not stored | |
| 139 | + | ttl := h.authSvc.GetAccessTokenTTL() | |
| 140 | + | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl) | |
| 245 | 141 | if err != nil { | |
| 246 | 142 | log.Error().Err(err).Msg("failed to generate access token") | |
| 247 | 143 | w.Header().Set("Content-Type", "application/json") | |
| @@ -252,7 +148,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 252 | 148 | ||
| 253 | 149 | response := dto.TokenResponse{ | |
| 254 | 150 | Token: accessToken, | |
| 255 | - | ExpiresIn: 300, // 5 minutes in seconds | |
| 151 | + | ExpiresIn: int(ttl.Seconds()), | |
| 256 | 152 | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 257 | 153 | } | |
| 258 | 154 | ||
| @@ -272,24 +168,18 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 272 | 168 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | |
| 273 | 169 | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 274 | 170 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| 275 | - | return true, nil | |
| 171 | + | return true, &domain.TokenClaims{ | |
| 172 | + | Subject: username, | |
| 173 | + | Scopes: []string{"push", "pull"}, | |
| 174 | + | } | |
| 276 | 175 | } | |
| 277 | 176 | ||
| 278 | - | // Always try JWT token validation first — even in password-auth mode the | |
| 279 | - | // server issues JWTs on login, and the CLI sends them back via Basic Auth | |
| 280 | - | // for registry token exchange. | |
| 177 | + | // Validate JWT token sent via Basic Auth password field. | |
| 281 | 178 | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 282 | 179 | if err == nil && claims.Subject == username { | |
| 283 | 180 | return true, claims | |
| 284 | 181 | } | |
| 285 | 182 | ||
| 286 | - | // Fall back to password validation for direct username/password credentials. | |
| 287 | - | if h.authSvc.GetAuthType() == domain.AuthTypePassword { | |
| 288 | - | if h.authSvc.ValidatePassword(ctx, username, password) { | |
| 289 | - | return true, nil | |
| 290 | - | } | |
| 291 | - | } | |
| 292 | - | ||
| 293 | 183 | return false, nil | |
| 294 | 184 | } | |
| 295 | 185 | ||
| @@ -298,26 +188,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri | |||
| 298 | 188 | ||
| 299 | 189 | for _, reqScopeStr := range requestedScopes { | |
| 300 | 190 | reqScope, err := domain.ParseScope(reqScopeStr) | |
| 301 | - | if err != nil || reqScope.Type != domain.ScopeTypeRepository { | |
| 191 | + | if err != nil { | |
| 302 | 192 | continue | |
| 303 | 193 | } | |
| 304 | 194 | ||
| 305 | - | allowedActions := make([]string, 0, len(reqScope.Actions)) | |
| 306 | - | for _, action := range reqScope.Actions { | |
| 307 | - | if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) { | |
| 308 | - | allowedActions = append(allowedActions, action) | |
| 309 | - | } | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | if len(allowedActions) == 0 { | |
| 195 | + | var checkAccess func([]string, string, string) bool | |
| 196 | + | switch reqScope.Type { | |
| 197 | + | case domain.ScopeTypeRepository: | |
| 198 | + | checkAccess = domain.ScopesGrantRegistryAccess | |
| 199 | + | case domain.ScopeTypeAdmin: | |
| 200 | + | checkAccess = domain.ScopesGrantAdminAccess | |
| 201 | + | default: | |
| 313 | 202 | continue | |
| 314 | 203 | } | |
| 315 | 204 | ||
| 316 | - | effective = append(effective, (&domain.Scope{ | |
| 317 | - | Type: reqScope.Type, | |
| 318 | - | Name: reqScope.Name, | |
| 319 | - | Actions: allowedActions, | |
| 320 | - | }).String()) | |
| 205 | + | if s := buildEffectiveScope(reqScope, grantedScopes, checkAccess); s != "" { | |
| 206 | + | effective = append(effective, s) | |
| 207 | + | } | |
| 321 | 208 | } | |
| 322 | 209 | ||
| 323 | 210 | log.Debug(). | |
| @@ -329,31 +216,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri | |||
| 329 | 216 | return effective | |
| 330 | 217 | } | |
| 331 | 218 | ||
| 332 | - | func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool { | |
| 333 | - | for _, grantedScopeStr := range grantedScopes { | |
| 334 | - | scopeStr := strings.TrimSpace(grantedScopeStr) | |
| 335 | - | switch scopeStr { | |
| 336 | - | case domain.ScopeActionAll: | |
| 337 | - | return true | |
| 338 | - | case domain.ScopeActionPull, domain.ScopeActionPush: | |
| 339 | - | if scopeStr == action { | |
| 340 | - | return true | |
| 341 | - | } | |
| 342 | - | } | |
| 343 | - | ||
| 344 | - | scope, err := domain.ParseScope(scopeStr) | |
| 345 | - | if err != nil { | |
| 346 | - | continue | |
| 347 | - | } | |
| 348 | - | if scope.Type != domain.ScopeTypeRepository { | |
| 349 | - | continue | |
| 350 | - | } | |
| 351 | - | if scope.CanAccess(repoName, action) { | |
| 352 | - | return true | |
| 219 | + | // buildEffectiveScope filters a requested scope's actions through checkAccess and returns | |
| 220 | + | // the resulting scope string, or "" if no actions were granted. | |
| 221 | + | func buildEffectiveScope(reqScope *domain.Scope, grantedScopes []string, checkAccess func([]string, string, string) bool) string { | |
| 222 | + | allowedActions := make([]string, 0, len(reqScope.Actions)) | |
| 223 | + | for _, action := range reqScope.Actions { | |
| 224 | + | if checkAccess(grantedScopes, reqScope.Name, action) { | |
| 225 | + | allowedActions = append(allowedActions, action) | |
| 353 | 226 | } | |
| 354 | 227 | } | |
| 355 | - | ||
| 356 | - | return false | |
| 228 | + | if len(allowedActions) == 0 { | |
| 229 | + | return "" | |
| 230 | + | } | |
| 231 | + | return (&domain.Scope{ | |
| 232 | + | Type: reqScope.Type, | |
| 233 | + | Name: reqScope.Name, | |
| 234 | + | Actions: allowedActions, | |
| 235 | + | }).String() | |
| 357 | 236 | } | |
| 358 | 237 | ||
| 359 | 238 | // parseRequestedScopes extracts and validates scope parameters from the request. | |
| @@ -380,8 +259,8 @@ func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []s | |||
| 380 | 259 | continue | |
| 381 | 260 | } | |
| 382 | 261 | ||
| 383 | - | // Only allow repository scopes | |
| 384 | - | if scope.Type != "repository" { | |
| 262 | + | // Allow repository and admin scopes | |
| 263 | + | if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin { | |
| 385 | 264 | log.Debug(). | |
| 386 | 265 | Str("scope_type", scope.Type). | |
| 387 | 266 | Str("scope", scopeStr). | |
| @@ -39,21 +39,6 @@ func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap. | |||||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | - | // PasswordRequest represents the request body for POST /auth/password. | |||
| 43 | - | type PasswordRequest struct { | |||
| 44 | - | Username string `json:"username"` | |||
| 45 | - | Password string `json:"password"` | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // PasswordResponse represents the response from POST /auth/password. | |||
| 49 | - | type PasswordResponse struct { | |||
| 50 | - | Token string `json:"token"` | |||
| 51 | - | ExpiresIn int `json:"expires_in"` | |||
| 52 | - | IssuedAt string `json:"issued_at"` | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | const passwordSessionTTL = 24 * time.Hour | |||
| 56 | - | ||||
| 57 | func setNoStoreHeaders(w http.ResponseWriter) { | 42 | func setNoStoreHeaders(w http.ResponseWriter) { | ||
| 58 | w.Header().Set("Cache-Control", "no-store") | 43 | w.Header().Set("Cache-Control", "no-store") | ||
| 59 | w.Header().Set("Pragma", "no-cache") | 44 | w.Header().Set("Pragma", "no-cache") | ||
| @@ -76,104 +61,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 76 | } | 61 | } | ||
| 77 | 62 | ||||
| 78 | // handlePassword handles POST /auth/password requests. | 63 | // handlePassword handles POST /auth/password requests. | ||
| 79 | - | // Validates username/password and returns a daily session JWT. | 64 | + | // Password authentication has been removed; this endpoint always returns Gone. |
| 80 | - | // Only works when auth type is "password" - returns error for "token" auth type. | |||
| 81 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | 65 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | ||
| 82 | setNoStoreHeaders(w) | 66 | setNoStoreHeaders(w) | ||
| 83 | - | ||||
| 84 | - | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |||
| 85 | - | zerowrap.FieldLayer: "adapter", | |||
| 86 | - | zerowrap.FieldAdapter: "http", | |||
| 87 | - | zerowrap.FieldHandler: "auth", | |||
| 88 | - | zerowrap.FieldMethod: r.Method, | |||
| 89 | - | zerowrap.FieldPath: r.URL.Path, | |||
| 90 | - | }) | |||
| 91 | - | log := zerowrap.FromCtx(ctx) | |||
| 92 | - | ||||
| 93 | - | if r.Method != http.MethodPost { | |||
| 94 | - | w.Header().Set("Content-Type", "application/json") | |||
| 95 | - | w.WriteHeader(http.StatusMethodNotAllowed) | |||
| 96 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"}) | |||
| 97 | - | return | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // Check if auth is enabled | |||
| 101 | - | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |||
| 102 | - | w.Header().Set("Content-Type", "application/json") | |||
| 103 | - | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 104 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |||
| 105 | - | return | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // Password endpoint only works with password auth type | |||
| 109 | - | if h.authSvc.GetAuthType() != domain.AuthTypePassword { | |||
| 110 | - | w.Header().Set("Content-Type", "application/json") | |||
| 111 | - | w.WriteHeader(http.StatusBadRequest) | |||
| 112 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |||
| 113 | - | Error: "password authentication not configured, use token-based auth", | |||
| 114 | - | }) | |||
| 115 | - | return | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | // Parse request body | |||
| 119 | - | var req PasswordRequest | |||
| 120 | - | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||
| 121 | - | w.Header().Set("Content-Type", "application/json") | |||
| 122 | - | w.WriteHeader(http.StatusBadRequest) | |||
| 123 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"}) | |||
| 124 | - | return | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | if req.Username == "" || req.Password == "" { | |||
| 128 | - | w.Header().Set("Content-Type", "application/json") | |||
| 129 | - | w.WriteHeader(http.StatusBadRequest) | |||
| 130 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"}) | |||
| 131 | - | return | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | // Validate password | |||
| 135 | - | if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) { | |||
| 136 | - | log.Debug(). | |||
| 137 | - | Str("username", req.Username). | |||
| 138 | - | Msg("password authentication failed") | |||
| 139 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`) | |||
| 140 | - | w.Header().Set("Content-Type", "application/json") | |||
| 141 | - | w.WriteHeader(http.StatusUnauthorized) | |||
| 142 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"}) | |||
| 143 | - | return | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | // Generate daily session token with full access (push, pull, admin). | |||
| 147 | - | // This enforces regular re-authentication similar to short-session UX. | |||
| 148 | - | expiry := passwordSessionTTL | |||
| 149 | - | scopes := []string{"push", "pull", "admin:*:*"} | |||
| 150 | - | ||||
| 151 | - | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |||
| 152 | - | if err != nil { | |||
| 153 | - | log.Error().Err(err).Msg("failed to generate token") | |||
| 154 | - | w.Header().Set("Content-Type", "application/json") | |||
| 155 | - | w.WriteHeader(http.StatusInternalServerError) | |||
| 156 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"}) | |||
| 157 | - | return | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | response := PasswordResponse{ | |||
| 161 | - | Token: token, | |||
| 162 | - | ExpiresIn: int(expiry.Seconds()), | |||
| 163 | - | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | w.Header().Set("Content-Type", "application/json") | 67 | w.Header().Set("Content-Type", "application/json") | ||
| 167 | - | w.WriteHeader(http.StatusOK) | 68 | + | w.WriteHeader(http.StatusGone) |
| 168 | - | 69 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{ | |
| 169 | - | if err := json.NewEncoder(w).Encode(response); err != nil { | 70 | + | Error: "password authentication has been removed, use token-based auth", |
| 170 | - | log.Error().Err(err).Msg("failed to encode password response") | 71 | + | }) |
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | log.Debug(). | |||
| 174 | - | Str("username", req.Username). | |||
| 175 | - | Int("expires_in", response.ExpiresIn). | |||
| 176 | - | Msg("long-lived token issued via password auth") | |||
| 177 | } | 72 | } | ||
| 178 | 73 | ||||
| 179 | // handleToken handles GET /auth/token requests. | 74 | // handleToken handles GET /auth/token requests. | ||
| @@ -240,8 +135,9 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 240 | } | 135 | } | ||
| 241 | } | 136 | } | ||
| 242 | 137 | ||||
| 243 | - | // Generate a short-lived access token (5 minutes) - not stored | 138 | + | // Generate a short-lived access token with configurable TTL - not stored |
| 244 | - | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | 139 | + | ttl := h.authSvc.GetAccessTokenTTL() |
| 140 | + | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl) | |||
| 245 | if err != nil { | 141 | if err != nil { | ||
| 246 | log.Error().Err(err).Msg("failed to generate access token") | 142 | log.Error().Err(err).Msg("failed to generate access token") | ||
| 247 | w.Header().Set("Content-Type", "application/json") | 143 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -252,7 +148,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 252 | 148 | ||||
| 253 | response := dto.TokenResponse{ | 149 | response := dto.TokenResponse{ | ||
| 254 | Token: accessToken, | 150 | Token: accessToken, | ||
| 255 | - | ExpiresIn: 300, // 5 minutes in seconds | 151 | + | ExpiresIn: int(ttl.Seconds()), |
| 256 | IssuedAt: time.Now().UTC().Format(time.RFC3339), | 152 | IssuedAt: time.Now().UTC().Format(time.RFC3339), | ||
| 257 | } | 153 | } | ||
| 258 | 154 | ||||
| @@ -272,24 +168,18 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 272 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | 168 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | ||
| 273 | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | 169 | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | ||
| 274 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | 170 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | ||
| 275 | - | return true, nil | 171 | + | return true, &domain.TokenClaims{ |
| 172 | + | Subject: username, | |||
| 173 | + | Scopes: []string{"push", "pull"}, | |||
| 174 | + | } | |||
| 276 | } | 175 | } | ||
| 277 | 176 | ||||
| 278 | - | // Always try JWT token validation first — even in password-auth mode the | 177 | + | // Validate JWT token sent via Basic Auth password field. |
| 279 | - | // server issues JWTs on login, and the CLI sends them back via Basic Auth | |||
| 280 | - | // for registry token exchange. | |||
| 281 | claims, err := h.authSvc.ValidateToken(ctx, password) | 178 | claims, err := h.authSvc.ValidateToken(ctx, password) | ||
| 282 | if err == nil && claims.Subject == username { | 179 | if err == nil && claims.Subject == username { | ||
| 283 | return true, claims | 180 | return true, claims | ||
| 284 | } | 181 | } | ||
| 285 | 182 | ||||
| 286 | - | // Fall back to password validation for direct username/password credentials. | |||
| 287 | - | if h.authSvc.GetAuthType() == domain.AuthTypePassword { | |||
| 288 | - | if h.authSvc.ValidatePassword(ctx, username, password) { | |||
| 289 | - | return true, nil | |||
| 290 | - | } | |||
| 291 | - | } | |||
| 292 | - | ||||
| 293 | return false, nil | 183 | return false, nil | ||
| 294 | } | 184 | } | ||
| 295 | 185 | ||||
| @@ -298,26 +188,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri | |||||
| 298 | 188 | ||||
| 299 | for _, reqScopeStr := range requestedScopes { | 189 | for _, reqScopeStr := range requestedScopes { | ||
| 300 | reqScope, err := domain.ParseScope(reqScopeStr) | 190 | reqScope, err := domain.ParseScope(reqScopeStr) | ||
| 301 | - | if err != nil || reqScope.Type != domain.ScopeTypeRepository { | 191 | + | if err != nil { |
| 302 | continue | 192 | continue | ||
| 303 | } | 193 | } | ||
| 304 | 194 | ||||
| 305 | - | allowedActions := make([]string, 0, len(reqScope.Actions)) | 195 | + | var checkAccess func([]string, string, string) bool |
| 306 | - | for _, action := range reqScope.Actions { | 196 | + | switch reqScope.Type { |
| 307 | - | if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) { | 197 | + | case domain.ScopeTypeRepository: |
| 308 | - | allowedActions = append(allowedActions, action) | 198 | + | checkAccess = domain.ScopesGrantRegistryAccess |
| 309 | - | } | 199 | + | case domain.ScopeTypeAdmin: |
| 310 | - | } | 200 | + | checkAccess = domain.ScopesGrantAdminAccess |
| 311 | - | 201 | + | default: | |
| 312 | - | if len(allowedActions) == 0 { | |||
| 313 | continue | 202 | continue | ||
| 314 | } | 203 | } | ||
| 315 | 204 | ||||
| 316 | - | effective = append(effective, (&domain.Scope{ | 205 | + | if s := buildEffectiveScope(reqScope, grantedScopes, checkAccess); s != "" { |
| 317 | - | Type: reqScope.Type, | 206 | + | effective = append(effective, s) |
| 318 | - | Name: reqScope.Name, | 207 | + | } |
| 319 | - | Actions: allowedActions, | |||
| 320 | - | }).String()) | |||
| 321 | } | 208 | } | ||
| 322 | 209 | ||||
| 323 | log.Debug(). | 210 | log.Debug(). | ||
| @@ -329,31 +216,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri | |||||
| 329 | return effective | 216 | return effective | ||
| 330 | } | 217 | } | ||
| 331 | 218 | ||||
| 332 | - | func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool { | 219 | + | // buildEffectiveScope filters a requested scope's actions through checkAccess and returns |
| 333 | - | for _, grantedScopeStr := range grantedScopes { | 220 | + | // the resulting scope string, or "" if no actions were granted. |
| 334 | - | scopeStr := strings.TrimSpace(grantedScopeStr) | 221 | + | func buildEffectiveScope(reqScope *domain.Scope, grantedScopes []string, checkAccess func([]string, string, string) bool) string { |
| 335 | - | switch scopeStr { | 222 | + | allowedActions := make([]string, 0, len(reqScope.Actions)) |
| 336 | - | case domain.ScopeActionAll: | 223 | + | for _, action := range reqScope.Actions { |
| 337 | - | return true | 224 | + | if checkAccess(grantedScopes, reqScope.Name, action) { |
| 338 | - | case domain.ScopeActionPull, domain.ScopeActionPush: | 225 | + | allowedActions = append(allowedActions, action) |
| 339 | - | if scopeStr == action { | |||
| 340 | - | return true | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | scope, err := domain.ParseScope(scopeStr) | |||
| 345 | - | if err != nil { | |||
| 346 | - | continue | |||
| 347 | - | } | |||
| 348 | - | if scope.Type != domain.ScopeTypeRepository { | |||
| 349 | - | continue | |||
| 350 | - | } | |||
| 351 | - | if scope.CanAccess(repoName, action) { | |||
| 352 | - | return true | |||
| 353 | } | 226 | } | ||
| 354 | } | 227 | } | ||
| 355 | - | 228 | + | if len(allowedActions) == 0 { | |
| 356 | - | return false | 229 | + | return "" |
| 230 | + | } | |||
| 231 | + | return (&domain.Scope{ | |||
| 232 | + | Type: reqScope.Type, | |||
| 233 | + | Name: reqScope.Name, | |||
| 234 | + | Actions: allowedActions, | |||
| 235 | + | }).String() | |||
| 357 | } | 236 | } | ||
| 358 | 237 | ||||
| 359 | // parseRequestedScopes extracts and validates scope parameters from the request. | 238 | // parseRequestedScopes extracts and validates scope parameters from the request. | ||
| @@ -380,8 +259,8 @@ func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []s | |||||
| 380 | continue | 259 | continue | ||
| 381 | } | 260 | } | ||
| 382 | 261 | ||||
| 383 | - | // Only allow repository scopes | 262 | + | // Allow repository and admin scopes |
| 384 | - | if scope.Type != "repository" { | 263 | + | if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin { |
| 385 | log.Debug(). | 264 | log.Debug(). | ||
| 386 | Str("scope_type", scope.Type). | 265 | Str("scope_type", scope.Type). | ||
| 387 | Str("scope", scopeStr). | 266 | Str("scope", scopeStr). | ||
M internal/adapters/in/http/auth/handler_test.go +99 -138
| @@ -22,79 +22,8 @@ func testLogger() zerowrap.Logger { | |||
| 22 | 22 | return zerowrap.Default() | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | - | func TestHandler_Password_Success(t *testing.T) { | |
| 26 | - | authSvc := mocks.NewMockAuthService(t) | |
| 27 | - | ||
| 28 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 29 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 30 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 31 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). | |
| 32 | - | Return("jwt-token-here", nil) | |
| 33 | - | ||
| 34 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 35 | - | ||
| 36 | - | body := `{"username":"admin","password":"secret"}` | |
| 37 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 38 | - | req.Header.Set("Content-Type", "application/json") | |
| 39 | - | rec := httptest.NewRecorder() | |
| 40 | - | ||
| 41 | - | handler.ServeHTTP(rec, req) | |
| 42 | - | ||
| 43 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 44 | - | ||
| 45 | - | var resp PasswordResponse | |
| 46 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 47 | - | assert.NoError(t, err) | |
| 48 | - | assert.Equal(t, "jwt-token-here", resp.Token) | |
| 49 | - | assert.Equal(t, 24*60*60, resp.ExpiresIn) | |
| 50 | - | assert.NotEmpty(t, resp.IssuedAt) | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | func TestHandler_Password_InvalidCredentials(t *testing.T) { | |
| 54 | - | authSvc := mocks.NewMockAuthService(t) | |
| 55 | - | ||
| 56 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 57 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 58 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |
| 59 | - | ||
| 60 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 61 | - | ||
| 62 | - | body := `{"username":"admin","password":"wrongpassword"}` | |
| 63 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 64 | - | req.Header.Set("Content-Type", "application/json") | |
| 65 | - | rec := httptest.NewRecorder() | |
| 66 | - | ||
| 67 | - | handler.ServeHTTP(rec, req) | |
| 68 | - | ||
| 69 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 70 | - | assert.Contains(t, rec.Body.String(), "invalid credentials") | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func TestHandler_Password_AuthTypeToken(t *testing.T) { | |
| 74 | - | authSvc := mocks.NewMockAuthService(t) | |
| 75 | - | ||
| 76 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 77 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 78 | - | ||
| 79 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 80 | - | ||
| 81 | - | body := `{"username":"admin","password":"secret"}` | |
| 82 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| 83 | - | req.Header.Set("Content-Type", "application/json") | |
| 84 | - | rec := httptest.NewRecorder() | |
| 85 | - | ||
| 86 | - | handler.ServeHTTP(rec, req) | |
| 87 | - | ||
| 88 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 89 | - | assert.Contains(t, rec.Body.String(), "password authentication not configured") | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | func TestHandler_Password_AuthDisabled(t *testing.T) { | |
| 93 | - | authSvc := mocks.NewMockAuthService(t) | |
| 94 | - | ||
| 95 | - | authSvc.EXPECT().IsEnabled().Return(false) | |
| 96 | - | ||
| 97 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 25 | + | func TestHandler_Password_AlwaysGone(t *testing.T) { | |
| 26 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 98 | 27 | ||
| 99 | 28 | body := `{"username":"admin","password":"secret"}` | |
| 100 | 29 | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |
| @@ -103,73 +32,26 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||
| 103 | 32 | ||
| 104 | 33 | handler.ServeHTTP(rec, req) | |
| 105 | 34 | ||
| 106 | - | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |
| 107 | - | assert.Contains(t, rec.Body.String(), "authentication is required") | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | func TestHandler_Password_MissingFields(t *testing.T) { | |
| 111 | - | authSvc := mocks.NewMockAuthService(t) | |
| 112 | - | ||
| 113 | - | authSvc.EXPECT().IsEnabled().Return(true) | |
| 114 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 115 | - | ||
| 116 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 117 | - | ||
| 118 | - | tests := []struct { | |
| 119 | - | name string | |
| 120 | - | body string | |
| 121 | - | }{ | |
| 122 | - | {"missing username", `{"password":"secret"}`}, | |
| 123 | - | {"missing password", `{"username":"admin"}`}, | |
| 124 | - | {"empty username", `{"username":"","password":"secret"}`}, | |
| 125 | - | {"empty password", `{"username":"admin","password":""}`}, | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | for _, tt := range tests { | |
| 129 | - | t.Run(tt.name, func(t *testing.T) { | |
| 130 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body)) | |
| 131 | - | req.Header.Set("Content-Type", "application/json") | |
| 132 | - | rec := httptest.NewRecorder() | |
| 133 | - | ||
| 134 | - | handler.ServeHTTP(rec, req) | |
| 135 | - | ||
| 136 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 137 | - | }) | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | func TestHandler_Password_MethodNotAllowed(t *testing.T) { | |
| 142 | - | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 143 | - | ||
| 144 | - | methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} | |
| 145 | - | ||
| 146 | - | for _, method := range methods { | |
| 147 | - | t.Run(method, func(t *testing.T) { | |
| 148 | - | req := httptest.NewRequest(method, "/auth/password", nil) | |
| 149 | - | rec := httptest.NewRecorder() | |
| 150 | - | ||
| 151 | - | handler.ServeHTTP(rec, req) | |
| 152 | - | ||
| 153 | - | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |
| 154 | - | }) | |
| 155 | - | } | |
| 35 | + | assert.Equal(t, http.StatusGone, rec.Code) | |
| 36 | + | assert.Contains(t, rec.Body.String(), "password authentication has been removed") | |
| 156 | 37 | } | |
| 157 | 38 | ||
| 158 | 39 | func TestHandler_Token_Success(t *testing.T) { | |
| 159 | 40 | authSvc := mocks.NewMockAuthService(t) | |
| 160 | 41 | ||
| 161 | 42 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 162 | - | // ValidateToken is tried first; plain password fails JWT parsing | |
| 163 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "secret").Return(nil, fmt.Errorf("not a token")) | |
| 164 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 165 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 166 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute). | |
| 43 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 44 | + | Subject: "admin", | |
| 45 | + | Scopes: []string{"repository:myrepo:pull"}, | |
| 46 | + | }, nil) | |
| 47 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 48 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute). | |
| 167 | 49 | Return("access-token", nil) | |
| 168 | 50 | ||
| 169 | 51 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 170 | 52 | ||
| 171 | 53 | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | |
| 172 | - | req.SetBasicAuth("admin", "secret") | |
| 54 | + | req.SetBasicAuth("admin", "existing-jwt-token") | |
| 173 | 55 | rec := httptest.NewRecorder() | |
| 174 | 56 | ||
| 175 | 57 | handler.ServeHTTP(rec, req) | |
| @@ -180,7 +62,7 @@ func TestHandler_Token_Success(t *testing.T) { | |||
| 180 | 62 | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 181 | 63 | assert.NoError(t, err) | |
| 182 | 64 | assert.Equal(t, "access-token", resp["token"]) | |
| 183 | - | assert.Equal(t, float64(300), resp["expires_in"]) | |
| 65 | + | assert.Equal(t, float64(900), resp["expires_in"]) | |
| 184 | 66 | } | |
| 185 | 67 | ||
| 186 | 68 | func TestHandler_Token_MissingCredentials(t *testing.T) { | |
| @@ -218,14 +100,12 @@ func TestHandler_Token_InvalidCredentials(t *testing.T) { | |||
| 218 | 100 | authSvc := mocks.NewMockAuthService(t) | |
| 219 | 101 | ||
| 220 | 102 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 221 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "wrongpassword").Return(nil, fmt.Errorf("not a token")) | |
| 222 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 223 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |
| 103 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token")) | |
| 224 | 104 | ||
| 225 | 105 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 226 | 106 | ||
| 227 | 107 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 228 | - | req.SetBasicAuth("admin", "wrongpassword") | |
| 108 | + | req.SetBasicAuth("admin", "invalid-token") | |
| 229 | 109 | rec := httptest.NewRecorder() | |
| 230 | 110 | ||
| 231 | 111 | handler.ServeHTTP(rec, req) | |
| @@ -237,7 +117,8 @@ func TestHandler_Token_InternalAuth(t *testing.T) { | |||
| 237 | 117 | authSvc := mocks.NewMockAuthService(t) | |
| 238 | 118 | ||
| 239 | 119 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 240 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute). | |
| 120 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 121 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute). | |
| 241 | 122 | Return("internal-access-token", nil) | |
| 242 | 123 | ||
| 243 | 124 | internalAuth := InternalAuth{ | |
| @@ -269,7 +150,8 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||
| 269 | 150 | Subject: "myuser", | |
| 270 | 151 | Scopes: []string{"repository:*:pull"}, | |
| 271 | 152 | }, nil) | |
| 272 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | |
| 153 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 154 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute). | |
| 273 | 155 | Return("short-lived-token", nil) | |
| 274 | 156 | ||
| 275 | 157 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -296,7 +178,8 @@ func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) { | |||
| 296 | 178 | Subject: "myuser", | |
| 297 | 179 | Scopes: []string{"repository:myrepo:pull"}, | |
| 298 | 180 | }, nil) | |
| 299 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute). | |
| 181 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 182 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute). | |
| 300 | 183 | Return("scoped-token", nil) | |
| 301 | 184 | ||
| 302 | 185 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -344,7 +227,8 @@ func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) { | |||
| 344 | 227 | Subject: "brissou", | |
| 345 | 228 | Scopes: []string{"*"}, | |
| 346 | 229 | }, nil) | |
| 347 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 5*time.Minute). | |
| 230 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 231 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute). | |
| 348 | 232 | Return("short-lived-token", nil) | |
| 349 | 233 | ||
| 350 | 234 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -563,3 +447,80 @@ func TestHandler_isInternalAuth(t *testing.T) { | |||
| 563 | 447 | }) | |
| 564 | 448 | } | |
| 565 | 449 | } | |
| 450 | + | ||
| 451 | + | func TestHandler_Token_AdminScopeSuccess(t *testing.T) { | |
| 452 | + | authSvc := mocks.NewMockAuthService(t) | |
| 453 | + | ||
| 454 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 455 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{ | |
| 456 | + | Subject: "admin", | |
| 457 | + | Scopes: []string{"admin:routes:read,write"}, | |
| 458 | + | }, nil) | |
| 459 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 460 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute). | |
| 461 | + | Return("admin-access-token", nil) | |
| 462 | + | ||
| 463 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 464 | + | ||
| 465 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil) | |
| 466 | + | req.SetBasicAuth("admin", "admin-jwt-token") | |
| 467 | + | rec := httptest.NewRecorder() | |
| 468 | + | ||
| 469 | + | handler.ServeHTTP(rec, req) | |
| 470 | + | ||
| 471 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 472 | + | ||
| 473 | + | var resp map[string]any | |
| 474 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 475 | + | assert.NoError(t, err) | |
| 476 | + | assert.Equal(t, "admin-access-token", resp["token"]) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) { | |
| 480 | + | authSvc := mocks.NewMockAuthService(t) | |
| 481 | + | ||
| 482 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 483 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{ | |
| 484 | + | Subject: "myuser", | |
| 485 | + | Scopes: []string{"repository:myrepo:pull,push"}, | |
| 486 | + | }, nil) | |
| 487 | + | ||
| 488 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 489 | + | ||
| 490 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil) | |
| 491 | + | req.SetBasicAuth("myuser", "registry-jwt-token") | |
| 492 | + | rec := httptest.NewRecorder() | |
| 493 | + | ||
| 494 | + | handler.ServeHTTP(rec, req) | |
| 495 | + | ||
| 496 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 497 | + | assert.Contains(t, rec.Body.String(), "insufficient scope") | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func TestHandler_Token_AdminScopeWildcard(t *testing.T) { | |
| 501 | + | authSvc := mocks.NewMockAuthService(t) | |
| 502 | + | ||
| 503 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 504 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{ | |
| 505 | + | Subject: "superadmin", | |
| 506 | + | Scopes: []string{"admin:*:*"}, | |
| 507 | + | }, nil) | |
| 508 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |
| 509 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute). | |
| 510 | + | Return("wildcard-access-token", nil) | |
| 511 | + | ||
| 512 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 513 | + | ||
| 514 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil) | |
| 515 | + | req.SetBasicAuth("superadmin", "admin-wildcard-token") | |
| 516 | + | rec := httptest.NewRecorder() | |
| 517 | + | ||
| 518 | + | handler.ServeHTTP(rec, req) | |
| 519 | + | ||
| 520 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 521 | + | ||
| 522 | + | var resp map[string]any | |
| 523 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 524 | + | assert.NoError(t, err) | |
| 525 | + | assert.Equal(t, "wildcard-access-token", resp["token"]) | |
| 526 | + | } | |
| @@ -22,79 +22,8 @@ func testLogger() zerowrap.Logger { | |||||
| 22 | return zerowrap.Default() | 22 | return zerowrap.Default() | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | func TestHandler_Password_Success(t *testing.T) { | 25 | + | func TestHandler_Password_AlwaysGone(t *testing.T) { |
| 26 | - | authSvc := mocks.NewMockAuthService(t) | 26 | + | handler := NewHandler(nil, InternalAuth{}, testLogger()) |
| 27 | - | ||||
| 28 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 29 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 30 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |||
| 31 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). | |||
| 32 | - | Return("jwt-token-here", nil) | |||
| 33 | - | ||||
| 34 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 35 | - | ||||
| 36 | - | body := `{"username":"admin","password":"secret"}` | |||
| 37 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 38 | - | req.Header.Set("Content-Type", "application/json") | |||
| 39 | - | rec := httptest.NewRecorder() | |||
| 40 | - | ||||
| 41 | - | handler.ServeHTTP(rec, req) | |||
| 42 | - | ||||
| 43 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 44 | - | ||||
| 45 | - | var resp PasswordResponse | |||
| 46 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 47 | - | assert.NoError(t, err) | |||
| 48 | - | assert.Equal(t, "jwt-token-here", resp.Token) | |||
| 49 | - | assert.Equal(t, 24*60*60, resp.ExpiresIn) | |||
| 50 | - | assert.NotEmpty(t, resp.IssuedAt) | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | func TestHandler_Password_InvalidCredentials(t *testing.T) { | |||
| 54 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 55 | - | ||||
| 56 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 57 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 58 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |||
| 59 | - | ||||
| 60 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 61 | - | ||||
| 62 | - | body := `{"username":"admin","password":"wrongpassword"}` | |||
| 63 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 64 | - | req.Header.Set("Content-Type", "application/json") | |||
| 65 | - | rec := httptest.NewRecorder() | |||
| 66 | - | ||||
| 67 | - | handler.ServeHTTP(rec, req) | |||
| 68 | - | ||||
| 69 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 70 | - | assert.Contains(t, rec.Body.String(), "invalid credentials") | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | func TestHandler_Password_AuthTypeToken(t *testing.T) { | |||
| 74 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 75 | - | ||||
| 76 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 77 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 78 | - | ||||
| 79 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 80 | - | ||||
| 81 | - | body := `{"username":"admin","password":"secret"}` | |||
| 82 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | |||
| 83 | - | req.Header.Set("Content-Type", "application/json") | |||
| 84 | - | rec := httptest.NewRecorder() | |||
| 85 | - | ||||
| 86 | - | handler.ServeHTTP(rec, req) | |||
| 87 | - | ||||
| 88 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 89 | - | assert.Contains(t, rec.Body.String(), "password authentication not configured") | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestHandler_Password_AuthDisabled(t *testing.T) { | |||
| 93 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 94 | - | ||||
| 95 | - | authSvc.EXPECT().IsEnabled().Return(false) | |||
| 96 | - | ||||
| 97 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 98 | 27 | ||||
| 99 | body := `{"username":"admin","password":"secret"}` | 28 | body := `{"username":"admin","password":"secret"}` | ||
| 100 | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | 29 | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body)) | ||
| @@ -103,73 +32,26 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||||
| 103 | 32 | ||||
| 104 | handler.ServeHTTP(rec, req) | 33 | handler.ServeHTTP(rec, req) | ||
| 105 | 34 | ||||
| 106 | - | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | 35 | + | assert.Equal(t, http.StatusGone, rec.Code) |
| 107 | - | assert.Contains(t, rec.Body.String(), "authentication is required") | 36 | + | assert.Contains(t, rec.Body.String(), "password authentication has been removed") |
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | func TestHandler_Password_MissingFields(t *testing.T) { | |||
| 111 | - | authSvc := mocks.NewMockAuthService(t) | |||
| 112 | - | ||||
| 113 | - | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 114 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 115 | - | ||||
| 116 | - | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 117 | - | ||||
| 118 | - | tests := []struct { | |||
| 119 | - | name string | |||
| 120 | - | body string | |||
| 121 | - | }{ | |||
| 122 | - | {"missing username", `{"password":"secret"}`}, | |||
| 123 | - | {"missing password", `{"username":"admin"}`}, | |||
| 124 | - | {"empty username", `{"username":"","password":"secret"}`}, | |||
| 125 | - | {"empty password", `{"username":"admin","password":""}`}, | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | for _, tt := range tests { | |||
| 129 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | - | req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body)) | |||
| 131 | - | req.Header.Set("Content-Type", "application/json") | |||
| 132 | - | rec := httptest.NewRecorder() | |||
| 133 | - | ||||
| 134 | - | handler.ServeHTTP(rec, req) | |||
| 135 | - | ||||
| 136 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 137 | - | }) | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | func TestHandler_Password_MethodNotAllowed(t *testing.T) { | |||
| 142 | - | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |||
| 143 | - | ||||
| 144 | - | methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} | |||
| 145 | - | ||||
| 146 | - | for _, method := range methods { | |||
| 147 | - | t.Run(method, func(t *testing.T) { | |||
| 148 | - | req := httptest.NewRequest(method, "/auth/password", nil) | |||
| 149 | - | rec := httptest.NewRecorder() | |||
| 150 | - | ||||
| 151 | - | handler.ServeHTTP(rec, req) | |||
| 152 | - | ||||
| 153 | - | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 154 | - | }) | |||
| 155 | - | } | |||
| 156 | } | 37 | } | ||
| 157 | 38 | ||||
| 158 | func TestHandler_Token_Success(t *testing.T) { | 39 | func TestHandler_Token_Success(t *testing.T) { | ||
| 159 | authSvc := mocks.NewMockAuthService(t) | 40 | authSvc := mocks.NewMockAuthService(t) | ||
| 160 | 41 | ||||
| 161 | authSvc.EXPECT().IsEnabled().Return(true) | 42 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 162 | - | // ValidateToken is tried first; plain password fails JWT parsing | 43 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ |
| 163 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "secret").Return(nil, fmt.Errorf("not a token")) | 44 | + | Subject: "admin", |
| 164 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | 45 | + | Scopes: []string{"repository:myrepo:pull"}, |
| 165 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | 46 | + | }, nil) |
| 166 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute). | 47 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) |
| 48 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute). | |||
| 167 | Return("access-token", nil) | 49 | Return("access-token", nil) | ||
| 168 | 50 | ||||
| 169 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 51 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| 170 | 52 | ||||
| 171 | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | 53 | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil) | ||
| 172 | - | req.SetBasicAuth("admin", "secret") | 54 | + | req.SetBasicAuth("admin", "existing-jwt-token") |
| 173 | rec := httptest.NewRecorder() | 55 | rec := httptest.NewRecorder() | ||
| 174 | 56 | ||||
| 175 | handler.ServeHTTP(rec, req) | 57 | handler.ServeHTTP(rec, req) | ||
| @@ -180,7 +62,7 @@ func TestHandler_Token_Success(t *testing.T) { | |||||
| 180 | err := json.NewDecoder(rec.Body).Decode(&resp) | 62 | err := json.NewDecoder(rec.Body).Decode(&resp) | ||
| 181 | assert.NoError(t, err) | 63 | assert.NoError(t, err) | ||
| 182 | assert.Equal(t, "access-token", resp["token"]) | 64 | assert.Equal(t, "access-token", resp["token"]) | ||
| 183 | - | assert.Equal(t, float64(300), resp["expires_in"]) | 65 | + | assert.Equal(t, float64(900), resp["expires_in"]) |
| 184 | } | 66 | } | ||
| 185 | 67 | ||||
| 186 | func TestHandler_Token_MissingCredentials(t *testing.T) { | 68 | func TestHandler_Token_MissingCredentials(t *testing.T) { | ||
| @@ -218,14 +100,12 @@ func TestHandler_Token_InvalidCredentials(t *testing.T) { | |||||
| 218 | authSvc := mocks.NewMockAuthService(t) | 100 | authSvc := mocks.NewMockAuthService(t) | ||
| 219 | 101 | ||||
| 220 | authSvc.EXPECT().IsEnabled().Return(true) | 102 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 221 | - | authSvc.EXPECT().ValidateToken(mock.Anything, "wrongpassword").Return(nil, fmt.Errorf("not a token")) | 103 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token")) |
| 222 | - | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |||
| 223 | - | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false) | |||
| 224 | 104 | ||||
| 225 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 105 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| 226 | 106 | ||||
| 227 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | 107 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | ||
| 228 | - | req.SetBasicAuth("admin", "wrongpassword") | 108 | + | req.SetBasicAuth("admin", "invalid-token") |
| 229 | rec := httptest.NewRecorder() | 109 | rec := httptest.NewRecorder() | ||
| 230 | 110 | ||||
| 231 | handler.ServeHTTP(rec, req) | 111 | handler.ServeHTTP(rec, req) | ||
| @@ -237,7 +117,8 @@ func TestHandler_Token_InternalAuth(t *testing.T) { | |||||
| 237 | authSvc := mocks.NewMockAuthService(t) | 117 | authSvc := mocks.NewMockAuthService(t) | ||
| 238 | 118 | ||||
| 239 | authSvc.EXPECT().IsEnabled().Return(true) | 119 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 240 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute). | 120 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) |
| 121 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute). | |||
| 241 | Return("internal-access-token", nil) | 122 | Return("internal-access-token", nil) | ||
| 242 | 123 | ||||
| 243 | internalAuth := InternalAuth{ | 124 | internalAuth := InternalAuth{ | ||
| @@ -269,7 +150,8 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||||
| 269 | Subject: "myuser", | 150 | Subject: "myuser", | ||
| 270 | Scopes: []string{"repository:*:pull"}, | 151 | Scopes: []string{"repository:*:pull"}, | ||
| 271 | }, nil) | 152 | }, nil) | ||
| 272 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | 153 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) |
| 154 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute). | |||
| 273 | Return("short-lived-token", nil) | 155 | Return("short-lived-token", nil) | ||
| 274 | 156 | ||||
| 275 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 157 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| @@ -296,7 +178,8 @@ func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) { | |||||
| 296 | Subject: "myuser", | 178 | Subject: "myuser", | ||
| 297 | Scopes: []string{"repository:myrepo:pull"}, | 179 | Scopes: []string{"repository:myrepo:pull"}, | ||
| 298 | }, nil) | 180 | }, nil) | ||
| 299 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute). | 181 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) |
| 182 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute). | |||
| 300 | Return("scoped-token", nil) | 183 | Return("scoped-token", nil) | ||
| 301 | 184 | ||||
| 302 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 185 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| @@ -344,7 +227,8 @@ func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) { | |||||
| 344 | Subject: "brissou", | 227 | Subject: "brissou", | ||
| 345 | Scopes: []string{"*"}, | 228 | Scopes: []string{"*"}, | ||
| 346 | }, nil) | 229 | }, nil) | ||
| 347 | - | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 5*time.Minute). | 230 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) |
| 231 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute). | |||
| 348 | Return("short-lived-token", nil) | 232 | Return("short-lived-token", nil) | ||
| 349 | 233 | ||||
| 350 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 234 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| @@ -563,3 +447,80 @@ func TestHandler_isInternalAuth(t *testing.T) { | |||||
| 563 | }) | 447 | }) | ||
| 564 | } | 448 | } | ||
| 565 | } | 449 | } | ||
| 450 | + | ||||
| 451 | + | func TestHandler_Token_AdminScopeSuccess(t *testing.T) { | |||
| 452 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 453 | + | ||||
| 454 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 455 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{ | |||
| 456 | + | Subject: "admin", | |||
| 457 | + | Scopes: []string{"admin:routes:read,write"}, | |||
| 458 | + | }, nil) | |||
| 459 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |||
| 460 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute). | |||
| 461 | + | Return("admin-access-token", nil) | |||
| 462 | + | ||||
| 463 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 464 | + | ||||
| 465 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil) | |||
| 466 | + | req.SetBasicAuth("admin", "admin-jwt-token") | |||
| 467 | + | rec := httptest.NewRecorder() | |||
| 468 | + | ||||
| 469 | + | handler.ServeHTTP(rec, req) | |||
| 470 | + | ||||
| 471 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 472 | + | ||||
| 473 | + | var resp map[string]any | |||
| 474 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 475 | + | assert.NoError(t, err) | |||
| 476 | + | assert.Equal(t, "admin-access-token", resp["token"]) | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) { | |||
| 480 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 481 | + | ||||
| 482 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 483 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{ | |||
| 484 | + | Subject: "myuser", | |||
| 485 | + | Scopes: []string{"repository:myrepo:pull,push"}, | |||
| 486 | + | }, nil) | |||
| 487 | + | ||||
| 488 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 489 | + | ||||
| 490 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil) | |||
| 491 | + | req.SetBasicAuth("myuser", "registry-jwt-token") | |||
| 492 | + | rec := httptest.NewRecorder() | |||
| 493 | + | ||||
| 494 | + | handler.ServeHTTP(rec, req) | |||
| 495 | + | ||||
| 496 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 497 | + | assert.Contains(t, rec.Body.String(), "insufficient scope") | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func TestHandler_Token_AdminScopeWildcard(t *testing.T) { | |||
| 501 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 502 | + | ||||
| 503 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 504 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{ | |||
| 505 | + | Subject: "superadmin", | |||
| 506 | + | Scopes: []string{"admin:*:*"}, | |||
| 507 | + | }, nil) | |||
| 508 | + | authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute) | |||
| 509 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute). | |||
| 510 | + | Return("wildcard-access-token", nil) | |||
| 511 | + | ||||
| 512 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 513 | + | ||||
| 514 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil) | |||
| 515 | + | req.SetBasicAuth("superadmin", "admin-wildcard-token") | |||
| 516 | + | rec := httptest.NewRecorder() | |||
| 517 | + | ||||
| 518 | + | handler.ServeHTTP(rec, req) | |||
| 519 | + | ||||
| 520 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 521 | + | ||||
| 522 | + | var resp map[string]any | |||
| 523 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 524 | + | assert.NoError(t, err) | |||
| 525 | + | assert.Equal(t, "wildcard-access-token", resp["token"]) | |||
| 526 | + | } | |||
M internal/adapters/in/http/middleware/auth.go +9 -70
| @@ -93,22 +93,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 93 | 93 | // (allows CI/CD tokens while still supporting interactive password login) | |
| 94 | 94 | var authenticated bool | |
| 95 | 95 | var tokenClaims *domain.TokenClaims | |
| 96 | - | switch authSvc.GetAuthType() { | |
| 97 | - | case domain.AuthTypePassword: | |
| 98 | - | // Try password auth first | |
| 99 | - | authenticated = authenticatePassword(ctx, r, authSvc, log) | |
| 100 | - | if !authenticated { | |
| 101 | - | // Fall back to token auth (for CI/CD tokens) | |
| 102 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 103 | - | } | |
| 104 | - | case domain.AuthTypeToken: | |
| 105 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 106 | - | default: | |
| 107 | - | log.Error(). | |
| 108 | - | Str("auth_type", string(authSvc.GetAuthType())). | |
| 109 | - | Msg("unknown auth type") | |
| 110 | - | authenticated = false | |
| 111 | - | } | |
| 96 | + | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 112 | 97 | ||
| 113 | 98 | if !authenticated { | |
| 114 | 99 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |
| @@ -132,34 +117,6 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 132 | 117 | } | |
| 133 | 118 | } | |
| 134 | 119 | ||
| 135 | - | // authenticatePassword handles password-based authentication. | |
| 136 | - | func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |
| 137 | - | username, password, ok := r.BasicAuth() | |
| 138 | - | if !ok { | |
| 139 | - | log.Debug(). | |
| 140 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 141 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 142 | - | Msg("no basic auth provided") | |
| 143 | - | return false | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | if authSvc.ValidatePassword(ctx, username, password) { | |
| 147 | - | log.Debug(). | |
| 148 | - | Str("username", username). | |
| 149 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 150 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 151 | - | Msg("password authentication successful") | |
| 152 | - | return true | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | log.Debug(). | |
| 156 | - | Str("provided_username", redactUsername(username)). | |
| 157 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 158 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 159 | - | Msg("password authentication failed") | |
| 160 | - | return false | |
| 161 | - | } | |
| 162 | - | ||
| 163 | 120 | // authenticateToken handles token-based authentication. | |
| 164 | 121 | // It supports both Bearer token in Authorization header and token-as-password for CI. | |
| 165 | 122 | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | |
| @@ -292,32 +249,14 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap. | |||
| 292 | 249 | return true | |
| 293 | 250 | } | |
| 294 | 251 | ||
| 295 | - | // Check if any token scope grants access | |
| 296 | - | for _, scopeStr := range claims.Scopes { | |
| 297 | - | // Handle simple scopes (e.g., "push", "pull") for backwards compatibility | |
| 298 | - | if scopeStr == action || scopeStr == "*" { | |
| 299 | - | log.Debug(). | |
| 300 | - | Str("repo", repoName). | |
| 301 | - | Str("action", action). | |
| 302 | - | Str("scope", scopeStr). | |
| 303 | - | Msg("simple scope access granted") | |
| 304 | - | return true | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull") | |
| 308 | - | scope, err := domain.ParseScope(scopeStr) | |
| 309 | - | if err != nil { | |
| 310 | - | continue | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | if scope.CanAccess(repoName, action) { | |
| 314 | - | log.Debug(). | |
| 315 | - | Str("repo", repoName). | |
| 316 | - | Str("action", action). | |
| 317 | - | Str("scope", scopeStr). | |
| 318 | - | Msg("scope access granted") | |
| 319 | - | return true | |
| 320 | - | } | |
| 252 | + | // Delegate matching to domain layer | |
| 253 | + | if domain.ScopesGrantRegistryAccess(claims.Scopes, repoName, action) { | |
| 254 | + | log.Debug(). | |
| 255 | + | Str("repo", repoName). | |
| 256 | + | Str("action", action). | |
| 257 | + | Strs("scopes", claims.Scopes). | |
| 258 | + | Msg("scope access granted") | |
| 259 | + | return true | |
| 321 | 260 | } | |
| 322 | 261 | ||
| 323 | 262 | log.Debug(). | |
| @@ -93,22 +93,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 93 | // (allows CI/CD tokens while still supporting interactive password login) | 93 | // (allows CI/CD tokens while still supporting interactive password login) | ||
| 94 | var authenticated bool | 94 | var authenticated bool | ||
| 95 | var tokenClaims *domain.TokenClaims | 95 | var tokenClaims *domain.TokenClaims | ||
| 96 | - | switch authSvc.GetAuthType() { | 96 | + | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) |
| 97 | - | case domain.AuthTypePassword: | |||
| 98 | - | // Try password auth first | |||
| 99 | - | authenticated = authenticatePassword(ctx, r, authSvc, log) | |||
| 100 | - | if !authenticated { | |||
| 101 | - | // Fall back to token auth (for CI/CD tokens) | |||
| 102 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |||
| 103 | - | } | |||
| 104 | - | case domain.AuthTypeToken: | |||
| 105 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |||
| 106 | - | default: | |||
| 107 | - | log.Error(). | |||
| 108 | - | Str("auth_type", string(authSvc.GetAuthType())). | |||
| 109 | - | Msg("unknown auth type") | |||
| 110 | - | authenticated = false | |||
| 111 | - | } | |||
| 112 | 97 | ||||
| 113 | if !authenticated { | 98 | if !authenticated { | ||
| 114 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | 99 | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | ||
| @@ -132,34 +117,6 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 132 | } | 117 | } | ||
| 133 | } | 118 | } | ||
| 134 | 119 | ||||
| 135 | - | // authenticatePassword handles password-based authentication. | |||
| 136 | - | func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool { | |||
| 137 | - | username, password, ok := r.BasicAuth() | |||
| 138 | - | if !ok { | |||
| 139 | - | log.Debug(). | |||
| 140 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 141 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 142 | - | Msg("no basic auth provided") | |||
| 143 | - | return false | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | if authSvc.ValidatePassword(ctx, username, password) { | |||
| 147 | - | log.Debug(). | |||
| 148 | - | Str("username", username). | |||
| 149 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 150 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 151 | - | Msg("password authentication successful") | |||
| 152 | - | return true | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | log.Debug(). | |||
| 156 | - | Str("provided_username", redactUsername(username)). | |||
| 157 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 158 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 159 | - | Msg("password authentication failed") | |||
| 160 | - | return false | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | // authenticateToken handles token-based authentication. | 120 | // authenticateToken handles token-based authentication. | ||
| 164 | // It supports both Bearer token in Authorization header and token-as-password for CI. | 121 | // It supports both Bearer token in Authorization header and token-as-password for CI. | ||
| 165 | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | 122 | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | ||
| @@ -292,32 +249,14 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap. | |||||
| 292 | return true | 249 | return true | ||
| 293 | } | 250 | } | ||
| 294 | 251 | ||||
| 295 | - | // Check if any token scope grants access | 252 | + | // Delegate matching to domain layer |
| 296 | - | for _, scopeStr := range claims.Scopes { | 253 | + | if domain.ScopesGrantRegistryAccess(claims.Scopes, repoName, action) { |
| 297 | - | // Handle simple scopes (e.g., "push", "pull") for backwards compatibility | 254 | + | log.Debug(). |
| 298 | - | if scopeStr == action || scopeStr == "*" { | 255 | + | Str("repo", repoName). |
| 299 | - | log.Debug(). | 256 | + | Str("action", action). |
| 300 | - | Str("repo", repoName). | 257 | + | Strs("scopes", claims.Scopes). |
| 301 | - | Str("action", action). | 258 | + | Msg("scope access granted") |
| 302 | - | Str("scope", scopeStr). | 259 | + | return true |
| 303 | - | Msg("simple scope access granted") | |||
| 304 | - | return true | |||
| 305 | - | } | |||
| 306 | - | ||||
| 307 | - | // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull") | |||
| 308 | - | scope, err := domain.ParseScope(scopeStr) | |||
| 309 | - | if err != nil { | |||
| 310 | - | continue | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | if scope.CanAccess(repoName, action) { | |||
| 314 | - | log.Debug(). | |||
| 315 | - | Str("repo", repoName). | |||
| 316 | - | Str("action", action). | |||
| 317 | - | Str("scope", scopeStr). | |||
| 318 | - | Msg("scope access granted") | |||
| 319 | - | return true | |||
| 320 | - | } | |||
| 321 | } | 260 | } | ||
| 322 | 261 | ||||
| 323 | log.Debug(). | 262 | log.Debug(). | ||
M internal/adapters/in/http/middleware/auth_test.go +27 -35
| @@ -20,13 +20,13 @@ func testLogger() zerowrap.Logger { | |||
| 20 | 20 | ||
| 21 | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |
| 22 | 22 | log := testLogger() | |
| 23 | - | called := false | |
| 23 | + | tokenCalled := false | |
| 24 | 24 | authSvc := stubAuthService{ | |
| 25 | 25 | enabled: true, | |
| 26 | - | authType: domain.AuthTypePassword, | |
| 27 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | |
| 28 | - | called = true | |
| 29 | - | return false | |
| 26 | + | authType: domain.AuthTypeToken, | |
| 27 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 28 | + | tokenCalled = true | |
| 29 | + | return nil, errors.New("not a token") | |
| 30 | 30 | }, | |
| 31 | 31 | } | |
| 32 | 32 | internalAuth := InternalRegistryAuth{ | |
| @@ -46,18 +46,18 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |||
| 46 | 46 | handler.ServeHTTP(rec, req) | |
| 47 | 47 | ||
| 48 | 48 | assert.Equal(t, http.StatusOK, rec.Code) | |
| 49 | - | assert.False(t, called, "auth service should not be used for internal loopback auth") | |
| 49 | + | assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth") | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |
| 53 | 53 | log := testLogger() | |
| 54 | - | called := false | |
| 54 | + | tokenCalled := false | |
| 55 | 55 | authSvc := stubAuthService{ | |
| 56 | 56 | enabled: true, | |
| 57 | - | authType: domain.AuthTypePassword, | |
| 58 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | |
| 59 | - | called = true | |
| 60 | - | return false | |
| 57 | + | authType: domain.AuthTypeToken, | |
| 58 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 59 | + | tokenCalled = true | |
| 60 | + | return nil, errors.New("invalid token") | |
| 61 | 61 | }, | |
| 62 | 62 | } | |
| 63 | 63 | internalAuth := InternalRegistryAuth{ | |
| @@ -77,19 +77,19 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |||
| 77 | 77 | handler.ServeHTTP(rec, req) | |
| 78 | 78 | ||
| 79 | 79 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 80 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 81 | - | assert.True(t, called, "auth service should be used when internal auth fails") | |
| 80 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") | |
| 81 | + | assert.True(t, tokenCalled, "auth service should be used when internal auth fails") | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |
| 85 | 85 | log := testLogger() | |
| 86 | - | called := false | |
| 86 | + | tokenCalled := false | |
| 87 | 87 | authSvc := stubAuthService{ | |
| 88 | 88 | enabled: true, | |
| 89 | - | authType: domain.AuthTypePassword, | |
| 90 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | |
| 91 | - | called = true | |
| 92 | - | return false | |
| 89 | + | authType: domain.AuthTypeToken, | |
| 90 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 91 | + | tokenCalled = true | |
| 92 | + | return nil, errors.New("invalid token") | |
| 93 | 93 | }, | |
| 94 | 94 | } | |
| 95 | 95 | internalAuth := InternalRegistryAuth{ | |
| @@ -109,15 +109,14 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |||
| 109 | 109 | handler.ServeHTTP(rec, req) | |
| 110 | 110 | ||
| 111 | 111 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 112 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 113 | - | assert.True(t, called, "auth service should be used for non-localhost requests") | |
| 112 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") | |
| 113 | + | assert.True(t, tokenCalled, "auth service should be used for non-localhost requests") | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | type stubAuthService struct { | |
| 117 | - | enabled bool | |
| 118 | - | authType domain.AuthType | |
| 119 | - | validatePassword func(ctx context.Context, username, password string) bool | |
| 120 | - | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |
| 117 | + | enabled bool | |
| 118 | + | authType domain.AuthType | |
| 119 | + | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |
| 121 | 120 | } | |
| 122 | 121 | ||
| 123 | 122 | func (s stubAuthService) GetAuthType() domain.AuthType { | |
| @@ -128,13 +127,6 @@ func (s stubAuthService) IsEnabled() bool { | |||
| 128 | 127 | return s.enabled | |
| 129 | 128 | } | |
| 130 | 129 | ||
| 131 | - | func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool { | |
| 132 | - | if s.validatePassword != nil { | |
| 133 | - | return s.validatePassword(ctx, username, password) | |
| 134 | - | } | |
| 135 | - | return false | |
| 136 | - | } | |
| 137 | - | ||
| 138 | 130 | func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | |
| 139 | 131 | if s.validateToken != nil { | |
| 140 | 132 | return s.validateToken(ctx, tokenString) | |
| @@ -162,10 +154,6 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) { | |||
| 162 | 154 | return nil, errors.New("not implemented") | |
| 163 | 155 | } | |
| 164 | 156 | ||
| 165 | - | func (s stubAuthService) GeneratePasswordHash(string) (string, error) { | |
| 166 | - | return "", errors.New("not implemented") | |
| 167 | - | } | |
| 168 | - | ||
| 169 | 157 | func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) { | |
| 170 | 158 | return nil, errors.New("not implemented") | |
| 171 | 159 | } | |
| @@ -174,6 +162,10 @@ func (s stubAuthService) ExtendToken(context.Context, string) (string, error) { | |||
| 174 | 162 | return "", errors.New("not implemented") | |
| 175 | 163 | } | |
| 176 | 164 | ||
| 165 | + | func (s stubAuthService) GetAccessTokenTTL() time.Duration { | |
| 166 | + | return 15 * time.Minute | |
| 167 | + | } | |
| 168 | + | ||
| 177 | 169 | // Tests for checkScopeAccess function | |
| 178 | 170 | ||
| 179 | 171 | func TestCheckScopeAccess_ActionMapping(t *testing.T) { | |
| @@ -20,13 +20,13 @@ func testLogger() zerowrap.Logger { | |||||
| 20 | 20 | ||||
| 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | ||
| 22 | log := testLogger() | 22 | log := testLogger() | ||
| 23 | - | called := false | 23 | + | tokenCalled := false |
| 24 | authSvc := stubAuthService{ | 24 | authSvc := stubAuthService{ | ||
| 25 | enabled: true, | 25 | enabled: true, | ||
| 26 | - | authType: domain.AuthTypePassword, | 26 | + | authType: domain.AuthTypeToken, |
| 27 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | 27 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { |
| 28 | - | called = true | 28 | + | tokenCalled = true |
| 29 | - | return false | 29 | + | return nil, errors.New("not a token") |
| 30 | }, | 30 | }, | ||
| 31 | } | 31 | } | ||
| 32 | internalAuth := InternalRegistryAuth{ | 32 | internalAuth := InternalRegistryAuth{ | ||
| @@ -46,18 +46,18 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |||||
| 46 | handler.ServeHTTP(rec, req) | 46 | handler.ServeHTTP(rec, req) | ||
| 47 | 47 | ||||
| 48 | assert.Equal(t, http.StatusOK, rec.Code) | 48 | assert.Equal(t, http.StatusOK, rec.Code) | ||
| 49 | - | assert.False(t, called, "auth service should not be used for internal loopback auth") | 49 | + | assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth") |
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | 52 | func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | ||
| 53 | log := testLogger() | 53 | log := testLogger() | ||
| 54 | - | called := false | 54 | + | tokenCalled := false |
| 55 | authSvc := stubAuthService{ | 55 | authSvc := stubAuthService{ | ||
| 56 | enabled: true, | 56 | enabled: true, | ||
| 57 | - | authType: domain.AuthTypePassword, | 57 | + | authType: domain.AuthTypeToken, |
| 58 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | 58 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { |
| 59 | - | called = true | 59 | + | tokenCalled = true |
| 60 | - | return false | 60 | + | return nil, errors.New("invalid token") |
| 61 | }, | 61 | }, | ||
| 62 | } | 62 | } | ||
| 63 | internalAuth := InternalRegistryAuth{ | 63 | internalAuth := InternalRegistryAuth{ | ||
| @@ -77,19 +77,19 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) { | |||||
| 77 | handler.ServeHTTP(rec, req) | 77 | handler.ServeHTTP(rec, req) | ||
| 78 | 78 | ||||
| 79 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | 79 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | ||
| 80 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | 80 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") |
| 81 | - | assert.True(t, called, "auth service should be used when internal auth fails") | 81 | + | assert.True(t, tokenCalled, "auth service should be used when internal auth fails") |
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | 84 | func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | ||
| 85 | log := testLogger() | 85 | log := testLogger() | ||
| 86 | - | called := false | 86 | + | tokenCalled := false |
| 87 | authSvc := stubAuthService{ | 87 | authSvc := stubAuthService{ | ||
| 88 | enabled: true, | 88 | enabled: true, | ||
| 89 | - | authType: domain.AuthTypePassword, | 89 | + | authType: domain.AuthTypeToken, |
| 90 | - | validatePassword: func(_ context.Context, _ string, _ string) bool { | 90 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { |
| 91 | - | called = true | 91 | + | tokenCalled = true |
| 92 | - | return false | 92 | + | return nil, errors.New("invalid token") |
| 93 | }, | 93 | }, | ||
| 94 | } | 94 | } | ||
| 95 | internalAuth := InternalRegistryAuth{ | 95 | internalAuth := InternalRegistryAuth{ | ||
| @@ -109,15 +109,14 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) { | |||||
| 109 | handler.ServeHTTP(rec, req) | 109 | handler.ServeHTTP(rec, req) | ||
| 110 | 110 | ||||
| 111 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | 111 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | ||
| 112 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | 112 | + | assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer") |
| 113 | - | assert.True(t, called, "auth service should be used for non-localhost requests") | 113 | + | assert.True(t, tokenCalled, "auth service should be used for non-localhost requests") |
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | type stubAuthService struct { | 116 | type stubAuthService struct { | ||
| 117 | - | enabled bool | 117 | + | enabled bool |
| 118 | - | authType domain.AuthType | 118 | + | authType domain.AuthType |
| 119 | - | validatePassword func(ctx context.Context, username, password string) bool | 119 | + | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) |
| 120 | - | validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |||
| 121 | } | 120 | } | ||
| 122 | 121 | ||||
| 123 | func (s stubAuthService) GetAuthType() domain.AuthType { | 122 | func (s stubAuthService) GetAuthType() domain.AuthType { | ||
| @@ -128,13 +127,6 @@ func (s stubAuthService) IsEnabled() bool { | |||||
| 128 | return s.enabled | 127 | return s.enabled | ||
| 129 | } | 128 | } | ||
| 130 | 129 | ||||
| 131 | - | func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool { | |||
| 132 | - | if s.validatePassword != nil { | |||
| 133 | - | return s.validatePassword(ctx, username, password) | |||
| 134 | - | } | |||
| 135 | - | return false | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | 130 | func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | ||
| 139 | if s.validateToken != nil { | 131 | if s.validateToken != nil { | ||
| 140 | return s.validateToken(ctx, tokenString) | 132 | return s.validateToken(ctx, tokenString) | ||
| @@ -162,10 +154,6 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) { | |||||
| 162 | return nil, errors.New("not implemented") | 154 | return nil, errors.New("not implemented") | ||
| 163 | } | 155 | } | ||
| 164 | 156 | ||||
| 165 | - | func (s stubAuthService) GeneratePasswordHash(string) (string, error) { | |||
| 166 | - | return "", errors.New("not implemented") | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) { | 157 | func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) { | ||
| 170 | return nil, errors.New("not implemented") | 158 | return nil, errors.New("not implemented") | ||
| 171 | } | 159 | } | ||
| @@ -174,6 +162,10 @@ func (s stubAuthService) ExtendToken(context.Context, string) (string, error) { | |||||
| 174 | return "", errors.New("not implemented") | 162 | return "", errors.New("not implemented") | ||
| 175 | } | 163 | } | ||
| 176 | 164 | ||||
| 165 | + | func (s stubAuthService) GetAccessTokenTTL() time.Duration { | |||
| 166 | + | return 15 * time.Minute | |||
| 167 | + | } | |||
| 168 | + | ||||
| 177 | // Tests for checkScopeAccess function | 169 | // Tests for checkScopeAccess function | ||
| 178 | 170 | ||||
| 179 | func TestCheckScopeAccess_ActionMapping(t *testing.T) { | 171 | func TestCheckScopeAccess_ActionMapping(t *testing.T) { | ||
M internal/adapters/in/http/proxy/forwarder.go +0 -5
| @@ -163,9 +163,6 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi | |||
| 163 | 163 | // and could conflict with Gordon's own security headers on browser-facing | |
| 164 | 164 | // routes. We strip them here to ensure clean, predictable registry responses. | |
| 165 | 165 | ModifyResponse: func(resp *http.Response) error { | |
| 166 | - | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 167 | - | resp.Header.Set("X-Registry-Backend", "gordon-registry") | |
| 168 | - | ||
| 169 | 166 | // Remove browser-oriented security headers injected by the upstream | |
| 170 | 167 | // registry. These are unnecessary for Docker client API traffic and | |
| 171 | 168 | // would conflict with headers set on browser-facing proxy routes. | |
| @@ -213,8 +210,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||
| 213 | 210 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | |
| 214 | 211 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | |
| 215 | 212 | return func(resp *http.Response) error { | |
| 216 | - | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 217 | - | ||
| 218 | 213 | if maxResponseSize > 0 { | |
| 219 | 214 | if resp.ContentLength > maxResponseSize { | |
| 220 | 215 | resp.Body.Close() | |
| @@ -163,9 +163,6 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi | |||||
| 163 | // and could conflict with Gordon's own security headers on browser-facing | 163 | // and could conflict with Gordon's own security headers on browser-facing | ||
| 164 | // routes. We strip them here to ensure clean, predictable registry responses. | 164 | // routes. We strip them here to ensure clean, predictable registry responses. | ||
| 165 | ModifyResponse: func(resp *http.Response) error { | 165 | ModifyResponse: func(resp *http.Response) error { | ||
| 166 | - | resp.Header.Set("X-Proxied-By", "Gordon") | |||
| 167 | - | resp.Header.Set("X-Registry-Backend", "gordon-registry") | |||
| 168 | - | ||||
| 169 | // Remove browser-oriented security headers injected by the upstream | 166 | // Remove browser-oriented security headers injected by the upstream | ||
| 170 | // registry. These are unnecessary for Docker client API traffic and | 167 | // registry. These are unnecessary for Docker client API traffic and | ||
| 171 | // would conflict with headers set on browser-facing proxy routes. | 168 | // would conflict with headers set on browser-facing proxy routes. | ||
| @@ -213,8 +210,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||||
| 213 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | 210 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | ||
| 214 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | 211 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | ||
| 215 | return func(resp *http.Response) error { | 212 | return func(resp *http.Response) error { | ||
| 216 | - | resp.Header.Set("X-Proxied-By", "Gordon") | |||
| 217 | - | ||||
| 218 | if maxResponseSize > 0 { | 213 | if maxResponseSize > 0 { | ||
| 219 | if resp.ContentLength > maxResponseSize { | 214 | if resp.ContentLength > maxResponseSize { | ||
| 220 | resp.Body.Close() | 215 | resp.Body.Close() | ||
M internal/adapters/in/http/proxy/forwarder_test.go +0 -1
| @@ -197,5 +197,4 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||
| 197 | 197 | handler.ServeHTTP(w, req) | |
| 198 | 198 | ||
| 199 | 199 | assert.Equal(t, http.StatusOK, w.Code) | |
| 200 | - | assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By")) | |
| 201 | 200 | } | |
| @@ -197,5 +197,4 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||||
| 197 | handler.ServeHTTP(w, req) | 197 | handler.ServeHTTP(w, req) | ||
| 198 | 198 | ||||
| 199 | assert.Equal(t, http.StatusOK, w.Code) | 199 | assert.Equal(t, http.StatusOK, w.Code) | ||
| 200 | - | assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By")) | |||
| 201 | } | 200 | } | ||
M internal/adapters/out/docker/runtime.go +8 -0
| @@ -161,11 +161,19 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||
| 161 | 161 | Labels: config.Labels, | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | + | resources := container.Resources{ | |
| 165 | + | Memory: config.MemoryLimit, | |
| 166 | + | NanoCPUs: config.NanoCPUs, | |
| 167 | + | } | |
| 168 | + | if config.PidsLimit > 0 { | |
| 169 | + | resources.PidsLimit = &config.PidsLimit | |
| 170 | + | } | |
| 164 | 171 | hostConfig := &container.HostConfig{ | |
| 165 | 172 | PortBindings: portBindings, | |
| 166 | 173 | AutoRemove: config.AutoRemove, | |
| 167 | 174 | Binds: binds, | |
| 168 | 175 | NetworkMode: container.NetworkMode(config.NetworkMode), | |
| 176 | + | Resources: resources, | |
| 169 | 177 | } | |
| 170 | 178 | ||
| 171 | 179 | // Create network configuration for container | |
| @@ -161,11 +161,19 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||||
| 161 | Labels: config.Labels, | 161 | Labels: config.Labels, | ||
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
| 164 | + | resources := container.Resources{ | |||
| 165 | + | Memory: config.MemoryLimit, | |||
| 166 | + | NanoCPUs: config.NanoCPUs, | |||
| 167 | + | } | |||
| 168 | + | if config.PidsLimit > 0 { | |||
| 169 | + | resources.PidsLimit = &config.PidsLimit | |||
| 170 | + | } | |||
| 164 | hostConfig := &container.HostConfig{ | 171 | hostConfig := &container.HostConfig{ | ||
| 165 | PortBindings: portBindings, | 172 | PortBindings: portBindings, | ||
| 166 | AutoRemove: config.AutoRemove, | 173 | AutoRemove: config.AutoRemove, | ||
| 167 | Binds: binds, | 174 | Binds: binds, | ||
| 168 | NetworkMode: container.NetworkMode(config.NetworkMode), | 175 | NetworkMode: container.NetworkMode(config.NetworkMode), | ||
| 176 | + | Resources: resources, | |||
| 169 | } | 177 | } | ||
| 170 | 178 | ||||
| 171 | // Create network configuration for container | 179 | // Create network configuration for container | ||
M internal/adapters/out/domainsecrets/store.go +18 -0
| @@ -219,9 +219,23 @@ func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, e | |||
| 219 | 219 | return secrets, nil | |
| 220 | 220 | } | |
| 221 | 221 | ||
| 222 | + | // validateSecretValues checks that no secret value contains newline characters. | |
| 223 | + | func validateSecretValues(secrets map[string]string) error { | |
| 224 | + | for key, value := range secrets { | |
| 225 | + | if strings.ContainsAny(value, "\n\r") { | |
| 226 | + | return fmt.Errorf("secret value for %q contains newline characters", key) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | return nil | |
| 230 | + | } | |
| 231 | + | ||
| 222 | 232 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |
| 223 | 233 | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 224 | 234 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | |
| 235 | + | if err := validateSecretValues(secrets); err != nil { | |
| 236 | + | return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err) | |
| 237 | + | } | |
| 238 | + | ||
| 225 | 239 | envFile, err := s.validateEnvFilePath(domainName) | |
| 226 | 240 | if err != nil { | |
| 227 | 241 | return err | |
| @@ -363,6 +377,10 @@ func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, | |||
| 363 | 377 | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |
| 364 | 378 | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 365 | 379 | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | |
| 380 | + | if err := validateSecretValues(secrets); err != nil { | |
| 381 | + | return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err) | |
| 382 | + | } | |
| 383 | + | ||
| 366 | 384 | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 367 | 385 | if err != nil { | |
| 368 | 386 | return err | |
| @@ -219,9 +219,23 @@ func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, e | |||||
| 219 | return secrets, nil | 219 | return secrets, nil | ||
| 220 | } | 220 | } | ||
| 221 | 221 | ||||
| 222 | + | // validateSecretValues checks that no secret value contains newline characters. | |||
| 223 | + | func validateSecretValues(secrets map[string]string) error { | |||
| 224 | + | for key, value := range secrets { | |||
| 225 | + | if strings.ContainsAny(value, "\n\r") { | |||
| 226 | + | return fmt.Errorf("secret value for %q contains newline characters", key) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 231 | + | ||||
| 222 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | 232 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | ||
| 223 | // It writes to a temporary file first, syncs it, then renames to the final path. | 233 | // It writes to a temporary file first, syncs it, then renames to the final path. | ||
| 224 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | 234 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | ||
| 235 | + | if err := validateSecretValues(secrets); err != nil { | |||
| 236 | + | return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 225 | envFile, err := s.validateEnvFilePath(domainName) | 239 | envFile, err := s.validateEnvFilePath(domainName) | ||
| 226 | if err != nil { | 240 | if err != nil { | ||
| 227 | return err | 241 | return err | ||
| @@ -363,6 +377,10 @@ func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, | |||||
| 363 | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | 377 | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | ||
| 364 | // It writes to a temporary file first, syncs it, then renames to the final path. | 378 | // It writes to a temporary file first, syncs it, then renames to the final path. | ||
| 365 | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | 379 | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | ||
| 380 | + | if err := validateSecretValues(secrets); err != nil { | |||
| 381 | + | return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 366 | envFile, err := s.validateAttachmentEnvFilePath(containerName) | 384 | envFile, err := s.validateAttachmentEnvFilePath(containerName) | ||
| 367 | if err != nil { | 385 | if err != nil { | ||
| 368 | return err | 386 | return err | ||
M internal/app/run.go +127 -78
| @@ -127,13 +127,12 @@ type Config struct { | |||
| 127 | 127 | ||
| 128 | 128 | Auth struct { | |
| 129 | 129 | Enabled bool `mapstructure:"enabled"` | |
| 130 | - | Type string `mapstructure:"type"` // "password" or "token" | |
| 130 | + | Type string `mapstructure:"type"` // only "token" is supported | |
| 131 | 131 | SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe" | |
| 132 | 132 | Username string `mapstructure:"username"` | |
| 133 | - | Password string `mapstructure:"password"` // deprecated: use password_hash | |
| 134 | - | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | |
| 135 | - | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |
| 136 | - | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d" | |
| 133 | + | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |
| 134 | + | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d" | |
| 135 | + | AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m) | |
| 137 | 136 | } `mapstructure:"auth"` | |
| 138 | 137 | ||
| 139 | 138 | API struct { | |
| @@ -166,6 +165,12 @@ type Config struct { | |||
| 166 | 165 | } `mapstructure:"prune"` | |
| 167 | 166 | } `mapstructure:"images"` | |
| 168 | 167 | ||
| 168 | + | Containers struct { | |
| 169 | + | MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB" | |
| 170 | + | CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core | |
| 171 | + | PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512 | |
| 172 | + | } `mapstructure:"containers"` | |
| 173 | + | ||
| 169 | 174 | Telemetry telemetry.Config `mapstructure:"telemetry"` | |
| 170 | 175 | } | |
| 171 | 176 | ||
| @@ -990,7 +995,10 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||
| 990 | 995 | return nil, nil, nil | |
| 991 | 996 | } | |
| 992 | 997 | ||
| 993 | - | authType := resolveAuthType(cfg) | |
| 998 | + | authType, err := resolveAuthType(cfg) | |
| 999 | + | if err != nil { | |
| 1000 | + | return nil, nil, fmt.Errorf("resolve auth type: %w", err) | |
| 1001 | + | } | |
| 994 | 1002 | backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend) | |
| 995 | 1003 | if err != nil { | |
| 996 | 1004 | return nil, nil, log.WrapErr(err, "failed to resolve secrets backend") | |
| @@ -1018,25 +1026,12 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||
| 1018 | 1026 | } | |
| 1019 | 1027 | ||
| 1020 | 1028 | // resolveAuthType determines the auth type from config. | |
| 1021 | - | // If password_hash is configured, password auth is available (plus tokens). | |
| 1022 | - | // If only token_secret is configured, token-only mode. | |
| 1023 | - | // The explicit "type" field is deprecated but still respected for backwards compat. | |
| 1024 | - | func resolveAuthType(cfg Config) domain.AuthType { | |
| 1025 | - | // Explicit type takes precedence (backwards compatibility) | |
| 1026 | - | if cfg.Auth.Type == "token" { | |
| 1027 | - | return domain.AuthTypeToken | |
| 1028 | - | } | |
| 1029 | - | if cfg.Auth.Type == "password" { | |
| 1030 | - | return domain.AuthTypePassword | |
| 1029 | + | // Token-only authentication is the only supported mode. | |
| 1030 | + | func resolveAuthType(cfg Config) (domain.AuthType, error) { | |
| 1031 | + | if cfg.Auth.Type != "" && cfg.Auth.Type != "token" { | |
| 1032 | + | return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type) | |
| 1031 | 1033 | } | |
| 1032 | - | ||
| 1033 | - | // Infer from config: password auth if password_hash is configured | |
| 1034 | - | if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" { | |
| 1035 | - | return domain.AuthTypePassword | |
| 1036 | - | } | |
| 1037 | - | ||
| 1038 | - | // Default to token-only | |
| 1039 | - | return domain.AuthTypeToken | |
| 1034 | + | return domain.AuthTypeToken, nil | |
| 1040 | 1035 | } | |
| 1041 | 1036 | ||
| 1042 | 1037 | func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) { | |
| @@ -1094,41 +1089,23 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||
| 1094 | 1089 | authConfig.TokenSecret = secret | |
| 1095 | 1090 | authConfig.TokenExpiry = expiry | |
| 1096 | 1091 | ||
| 1097 | - | // Password config only needed for password auth mode | |
| 1098 | - | if authType == domain.AuthTypePassword { | |
| 1099 | - | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | |
| 1092 | + | accessTokenTTL := 15 * time.Minute // default | |
| 1093 | + | if cfg.Auth.AccessTokenTTL != "" { | |
| 1094 | + | parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL) | |
| 1100 | 1095 | if err != nil { | |
| 1101 | - | return auth.Config{}, err | |
| 1096 | + | return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err) | |
| 1102 | 1097 | } | |
| 1103 | - | if hash == "" { | |
| 1104 | - | return auth.Config{}, errAuthNotConfigured() | |
| 1098 | + | if parsed <= 0 { | |
| 1099 | + | return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive") | |
| 1105 | 1100 | } | |
| 1106 | - | authConfig.PasswordHash = hash | |
| 1107 | - | } | |
| 1108 | - | ||
| 1109 | - | return authConfig, nil | |
| 1110 | - | } | |
| 1111 | - | ||
| 1112 | - | // errAuthNotConfigured returns an error when auth is enabled but credentials are not configured. | |
| 1113 | - | func errAuthNotConfigured() error { | |
| 1114 | - | return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth") | |
| 1115 | - | } | |
| 1116 | - | ||
| 1117 | - | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | |
| 1118 | - | if cfg.Auth.PasswordHash != "" { | |
| 1119 | - | hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log) | |
| 1120 | - | if err != nil { | |
| 1121 | - | return "", log.WrapErr(err, "failed to load password hash") | |
| 1101 | + | if parsed > auth.MaxAccessTokenLifetime { | |
| 1102 | + | return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime) | |
| 1122 | 1103 | } | |
| 1123 | - | return hash, nil | |
| 1124 | - | } | |
| 1125 | - | ||
| 1126 | - | if cfg.Auth.Password != "" { | |
| 1127 | - | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |
| 1128 | - | return cfg.Auth.Password, nil | |
| 1104 | + | accessTokenTTL = parsed | |
| 1129 | 1105 | } | |
| 1106 | + | authConfig.AccessTokenTTL = accessTokenTTL | |
| 1130 | 1107 | ||
| 1131 | - | return "", nil | |
| 1108 | + | return authConfig, nil | |
| 1132 | 1109 | } | |
| 1133 | 1110 | ||
| 1134 | 1111 | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { | |
| @@ -1155,8 +1132,13 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||
| 1155 | 1132 | // 2. Secrets backend (pass/sops - encrypted) | |
| 1156 | 1133 | // 3. Config file path (least preferred) | |
| 1157 | 1134 | ||
| 1135 | + | const minTokenSecretLength = 32 | |
| 1136 | + | ||
| 1158 | 1137 | // Check environment variable first | |
| 1159 | 1138 | if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" { | |
| 1139 | + | if len(envSecret) < minTokenSecretLength { | |
| 1140 | + | return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret)) | |
| 1141 | + | } | |
| 1160 | 1142 | log.Debug().Msg("using token secret from environment variable") | |
| 1161 | 1143 | return []byte(envSecret), nil | |
| 1162 | 1144 | } | |
| @@ -1171,6 +1153,10 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||
| 1171 | 1153 | return nil, log.WrapErr(err, "failed to load token secret") | |
| 1172 | 1154 | } | |
| 1173 | 1155 | ||
| 1156 | + | if len(secret) < minTokenSecretLength { | |
| 1157 | + | return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret)) | |
| 1158 | + | } | |
| 1159 | + | ||
| 1174 | 1160 | return []byte(secret), nil | |
| 1175 | 1161 | } | |
| 1176 | 1162 | ||
| @@ -1275,6 +1261,29 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||
| 1275 | 1261 | ||
| 1276 | 1262 | // createContainerService creates the container service with configuration. | |
| 1277 | 1263 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | |
| 1264 | + | // Parse and validate container resource limits from config | |
| 1265 | + | if cfg.Containers.CPULimit < 0 { | |
| 1266 | + | return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit) | |
| 1267 | + | } | |
| 1268 | + | if cfg.Containers.PidsLimit < 0 { | |
| 1269 | + | return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit) | |
| 1270 | + | } | |
| 1271 | + | var defaultMemoryLimit int64 | |
| 1272 | + | if cfg.Containers.MemoryLimit != "" { | |
| 1273 | + | parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit) | |
| 1274 | + | if err != nil { | |
| 1275 | + | return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err) | |
| 1276 | + | } | |
| 1277 | + | if parsed <= 0 { | |
| 1278 | + | return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit) | |
| 1279 | + | } | |
| 1280 | + | defaultMemoryLimit = parsed | |
| 1281 | + | } | |
| 1282 | + | var defaultNanoCPUs int64 | |
| 1283 | + | if cfg.Containers.CPULimit > 0 { | |
| 1284 | + | defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9) | |
| 1285 | + | } | |
| 1286 | + | ||
| 1278 | 1287 | containerConfig := container.Config{ | |
| 1279 | 1288 | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1280 | 1289 | RegistryDomain: cfg.Server.RegistryDomain, | |
| @@ -1298,6 +1307,9 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1298 | 1307 | DrainDelay: v.GetDuration("deploy.drain_delay"), | |
| 1299 | 1308 | DrainMode: v.GetString("deploy.drain_mode"), | |
| 1300 | 1309 | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | |
| 1310 | + | DefaultMemoryLimit: defaultMemoryLimit, | |
| 1311 | + | DefaultNanoCPUs: defaultNanoCPUs, | |
| 1312 | + | DefaultPidsLimit: cfg.Containers.PidsLimit, | |
| 1301 | 1313 | } | |
| 1302 | 1314 | if v.IsSet("deploy.drain_delay") { | |
| 1303 | 1315 | containerConfig.DrainDelayConfigured = true | |
| @@ -1542,7 +1554,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1542 | 1554 | ) | |
| 1543 | 1555 | ||
| 1544 | 1556 | registryMux := http.NewServeMux() | |
| 1545 | - | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | |
| 1557 | + | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log) | |
| 1546 | 1558 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | |
| 1547 | 1559 | ||
| 1548 | 1560 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |
| @@ -1675,12 +1687,22 @@ func registerAuthRoutes( | |||
| 1675 | 1687 | trustedNets []*net.IPNet, | |
| 1676 | 1688 | cidrAllowlistMiddleware func(http.Handler) http.Handler, | |
| 1677 | 1689 | rateLimitMiddleware func(http.Handler) http.Handler, | |
| 1690 | + | cfg Config, | |
| 1678 | 1691 | log zerowrap.Logger, | |
| 1679 | 1692 | ) { | |
| 1680 | 1693 | if svc.authHandler == nil { | |
| 1681 | 1694 | return | |
| 1682 | 1695 | } | |
| 1683 | 1696 | ||
| 1697 | + | // Auth endpoints always get rate limiting, even if global rate limiting is disabled. | |
| 1698 | + | // This prevents brute-force attacks against password/token endpoints. | |
| 1699 | + | authRateLimitMiddleware := rateLimitMiddleware | |
| 1700 | + | if !cfg.API.RateLimit.Enabled { | |
| 1701 | + | authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log) | |
| 1702 | + | authIPLimiter := ratelimit.NewMemoryStore(5, 10, log) | |
| 1703 | + | authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log) | |
| 1704 | + | } | |
| 1705 | + | ||
| 1684 | 1706 | // Auth endpoints are NOT protected by auth - they're where clients authenticate | |
| 1685 | 1707 | // but still need rate limiting to prevent brute force attacks. | |
| 1686 | 1708 | authMiddlewares := []func(http.Handler) http.Handler{ | |
| @@ -1691,7 +1713,7 @@ func registerAuthRoutes( | |||
| 1691 | 1713 | if cidrAllowlistMiddleware != nil { | |
| 1692 | 1714 | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |
| 1693 | 1715 | } | |
| 1694 | - | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |
| 1716 | + | authMiddlewares = append(authMiddlewares, authRateLimitMiddleware) | |
| 1695 | 1717 | authWithMiddleware := otelhttp.NewHandler( | |
| 1696 | 1718 | middleware.Chain(authMiddlewares...)(svc.authHandler), | |
| 1697 | 1719 | "gordon.auth", | |
| @@ -1774,27 +1796,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1774 | 1796 | ||
| 1775 | 1797 | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log) | |
| 1776 | 1798 | ||
| 1777 | - | // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can | |
| 1778 | - | // accept h2c traffic from clients and load balancers (e.g. Cloudflare). | |
| 1779 | - | var proxyProtos http.Protocols | |
| 1780 | - | proxyProtos.SetHTTP1(true) | |
| 1781 | - | proxyProtos.SetUnencryptedHTTP2(true) | |
| 1782 | - | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log) | |
| 1783 | - | var tlsSrv *http.Server | |
| 1784 | - | var tlsReady <-chan struct{} | |
| 1785 | - | if cfg.Server.TLSEnabled { | |
| 1786 | - | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | |
| 1787 | - | return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | |
| 1788 | - | } | |
| 1789 | - | tlsSrv, tlsReady = startTLSServer( | |
| 1790 | - | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 1791 | - | proxyHandler, | |
| 1792 | - | "proxy-tls", | |
| 1793 | - | cfg.Server.TLSCertFile, | |
| 1794 | - | cfg.Server.TLSKeyFile, | |
| 1795 | - | errChan, | |
| 1796 | - | log, | |
| 1797 | - | ) | |
| 1799 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log) | |
| 1800 | + | if err != nil { | |
| 1801 | + | return err | |
| 1798 | 1802 | } | |
| 1799 | 1803 | ||
| 1800 | 1804 | // Wait for all enabled servers to bind their ports before auto-starting containers. | |
| @@ -2075,6 +2079,52 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2075 | 2079 | log.Info().Msg("Gordon stopped") | |
| 2076 | 2080 | } | |
| 2077 | 2081 | ||
| 2082 | + | // startProxyServers sets up proxy server(s) depending on TLS configuration. | |
| 2083 | + | // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic. | |
| 2084 | + | // Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2. | |
| 2085 | + | func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 2086 | + | if !cfg.Server.TLSEnabled { | |
| 2087 | + | var proxyProtos http.Protocols | |
| 2088 | + | proxyProtos.SetHTTP1(true) | |
| 2089 | + | proxyProtos.SetUnencryptedHTTP2(true) | |
| 2090 | + | srv, ready := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log) | |
| 2091 | + | return srv, ready, nil, nil, nil | |
| 2092 | + | } | |
| 2093 | + | ||
| 2094 | + | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | |
| 2095 | + | return nil, nil, nil, nil, fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | |
| 2096 | + | } | |
| 2097 | + | ||
| 2098 | + | tlsPort := cfg.Server.TLSPort | |
| 2099 | + | redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 2100 | + | host := r.Host | |
| 2101 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 2102 | + | host = h | |
| 2103 | + | } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { | |
| 2104 | + | host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") | |
| 2105 | + | } | |
| 2106 | + | if tlsPort != 443 { | |
| 2107 | + | host = net.JoinHostPort(host, strconv.Itoa(tlsPort)) | |
| 2108 | + | } else if strings.Contains(host, ":") { | |
| 2109 | + | host = "[" + host + "]" | |
| 2110 | + | } | |
| 2111 | + | target := "https://" + host + r.RequestURI | |
| 2112 | + | http.Redirect(w, r, target, http.StatusPermanentRedirect) | |
| 2113 | + | }) | |
| 2114 | + | ||
| 2115 | + | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log) | |
| 2116 | + | tlsSrv, tlsReady := startTLSServer( | |
| 2117 | + | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 2118 | + | proxyHandler, | |
| 2119 | + | "proxy-tls", | |
| 2120 | + | cfg.Server.TLSCertFile, | |
| 2121 | + | cfg.Server.TLSKeyFile, | |
| 2122 | + | errChan, | |
| 2123 | + | log, | |
| 2124 | + | ) | |
| 2125 | + | return proxySrv, proxyReady, tlsSrv, tlsReady, nil | |
| 2126 | + | } | |
| 2127 | + | ||
| 2078 | 2128 | // startServer starts an HTTP server, returning the server instance and a channel | |
| 2079 | 2129 | // that closes once the listening socket is bound. This lets callers wait for the | |
| 2080 | 2130 | // port to be ready before taking actions that depend on it (e.g. auto-start | |
| @@ -2421,8 +2471,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2421 | 2471 | v.SetDefault("logging.container_logs.max_age", 28) | |
| 2422 | 2472 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | |
| 2423 | 2473 | v.SetDefault("auth.enabled", true) | |
| 2424 | - | // Note: auth.type is intentionally not set - it's inferred from config | |
| 2425 | - | // If password_hash is set -> password mode, otherwise -> token mode | |
| 2474 | + | // Note: auth.type defaults to "token" (the only supported mode) | |
| 2426 | 2475 | v.SetDefault("auth.secrets_backend", "") | |
| 2427 | 2476 | v.SetDefault("auth.token_expiry", "720h") | |
| 2428 | 2477 | v.SetDefault("api.rate_limit.enabled", true) | |
| @@ -127,13 +127,12 @@ type Config struct { | |||||
| 127 | 127 | ||||
| 128 | Auth struct { | 128 | Auth struct { | ||
| 129 | Enabled bool `mapstructure:"enabled"` | 129 | Enabled bool `mapstructure:"enabled"` | ||
| 130 | - | Type string `mapstructure:"type"` // "password" or "token" | 130 | + | Type string `mapstructure:"type"` // only "token" is supported |
| 131 | SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe" | 131 | SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe" | ||
| 132 | Username string `mapstructure:"username"` | 132 | Username string `mapstructure:"username"` | ||
| 133 | - | Password string `mapstructure:"password"` // deprecated: use password_hash | 133 | + | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend |
| 134 | - | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | 134 | + | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d" |
| 135 | - | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | 135 | + | AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m) |
| 136 | - | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d" | |||
| 137 | } `mapstructure:"auth"` | 136 | } `mapstructure:"auth"` | ||
| 138 | 137 | ||||
| 139 | API struct { | 138 | API struct { | ||
| @@ -166,6 +165,12 @@ type Config struct { | |||||
| 166 | } `mapstructure:"prune"` | 165 | } `mapstructure:"prune"` | ||
| 167 | } `mapstructure:"images"` | 166 | } `mapstructure:"images"` | ||
| 168 | 167 | ||||
| 168 | + | Containers struct { | |||
| 169 | + | MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB" | |||
| 170 | + | CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core | |||
| 171 | + | PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512 | |||
| 172 | + | } `mapstructure:"containers"` | |||
| 173 | + | ||||
| 169 | Telemetry telemetry.Config `mapstructure:"telemetry"` | 174 | Telemetry telemetry.Config `mapstructure:"telemetry"` | ||
| 170 | } | 175 | } | ||
| 171 | 176 | ||||
| @@ -990,7 +995,10 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||||
| 990 | return nil, nil, nil | 995 | return nil, nil, nil | ||
| 991 | } | 996 | } | ||
| 992 | 997 | ||||
| 993 | - | authType := resolveAuthType(cfg) | 998 | + | authType, err := resolveAuthType(cfg) |
| 999 | + | if err != nil { | |||
| 1000 | + | return nil, nil, fmt.Errorf("resolve auth type: %w", err) | |||
| 1001 | + | } | |||
| 994 | backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend) | 1002 | backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend) | ||
| 995 | if err != nil { | 1003 | if err != nil { | ||
| 996 | return nil, nil, log.WrapErr(err, "failed to resolve secrets backend") | 1004 | return nil, nil, log.WrapErr(err, "failed to resolve secrets backend") | ||
| @@ -1018,25 +1026,12 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||||
| 1018 | } | 1026 | } | ||
| 1019 | 1027 | ||||
| 1020 | // resolveAuthType determines the auth type from config. | 1028 | // resolveAuthType determines the auth type from config. | ||
| 1021 | - | // If password_hash is configured, password auth is available (plus tokens). | 1029 | + | // Token-only authentication is the only supported mode. |
| 1022 | - | // If only token_secret is configured, token-only mode. | 1030 | + | func resolveAuthType(cfg Config) (domain.AuthType, error) { |
| 1023 | - | // The explicit "type" field is deprecated but still respected for backwards compat. | 1031 | + | if cfg.Auth.Type != "" && cfg.Auth.Type != "token" { |
| 1024 | - | func resolveAuthType(cfg Config) domain.AuthType { | 1032 | + | return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type) |
| 1025 | - | // Explicit type takes precedence (backwards compatibility) | |||
| 1026 | - | if cfg.Auth.Type == "token" { | |||
| 1027 | - | return domain.AuthTypeToken | |||
| 1028 | - | } | |||
| 1029 | - | if cfg.Auth.Type == "password" { | |||
| 1030 | - | return domain.AuthTypePassword | |||
| 1031 | } | 1033 | } | ||
| 1032 | - | 1034 | + | return domain.AuthTypeToken, nil | |
| 1033 | - | // Infer from config: password auth if password_hash is configured | |||
| 1034 | - | if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" { | |||
| 1035 | - | return domain.AuthTypePassword | |||
| 1036 | - | } | |||
| 1037 | - | ||||
| 1038 | - | // Default to token-only | |||
| 1039 | - | return domain.AuthTypeToken | |||
| 1040 | } | 1035 | } | ||
| 1041 | 1036 | ||||
| 1042 | func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) { | 1037 | func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) { | ||
| @@ -1094,41 +1089,23 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||||
| 1094 | authConfig.TokenSecret = secret | 1089 | authConfig.TokenSecret = secret | ||
| 1095 | authConfig.TokenExpiry = expiry | 1090 | authConfig.TokenExpiry = expiry | ||
| 1096 | 1091 | ||||
| 1097 | - | // Password config only needed for password auth mode | 1092 | + | accessTokenTTL := 15 * time.Minute // default |
| 1098 | - | if authType == domain.AuthTypePassword { | 1093 | + | if cfg.Auth.AccessTokenTTL != "" { |
| 1099 | - | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | 1094 | + | parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL) |
| 1100 | if err != nil { | 1095 | if err != nil { | ||
| 1101 | - | return auth.Config{}, err | 1096 | + | return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err) |
| 1102 | } | 1097 | } | ||
| 1103 | - | if hash == "" { | 1098 | + | if parsed <= 0 { |
| 1104 | - | return auth.Config{}, errAuthNotConfigured() | 1099 | + | return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive") |
| 1105 | } | 1100 | } | ||
| 1106 | - | authConfig.PasswordHash = hash | 1101 | + | if parsed > auth.MaxAccessTokenLifetime { |
| 1107 | - | } | 1102 | + | return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime) |
| 1108 | - | ||||
| 1109 | - | return authConfig, nil | |||
| 1110 | - | } | |||
| 1111 | - | ||||
| 1112 | - | // errAuthNotConfigured returns an error when auth is enabled but credentials are not configured. | |||
| 1113 | - | func errAuthNotConfigured() error { | |||
| 1114 | - | return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth") | |||
| 1115 | - | } | |||
| 1116 | - | ||||
| 1117 | - | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | |||
| 1118 | - | if cfg.Auth.PasswordHash != "" { | |||
| 1119 | - | hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log) | |||
| 1120 | - | if err != nil { | |||
| 1121 | - | return "", log.WrapErr(err, "failed to load password hash") | |||
| 1122 | } | 1103 | } | ||
| 1123 | - | return hash, nil | 1104 | + | accessTokenTTL = parsed |
| 1124 | - | } | |||
| 1125 | - | ||||
| 1126 | - | if cfg.Auth.Password != "" { | |||
| 1127 | - | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |||
| 1128 | - | return cfg.Auth.Password, nil | |||
| 1129 | } | 1105 | } | ||
| 1106 | + | authConfig.AccessTokenTTL = accessTokenTTL | |||
| 1130 | 1107 | ||||
| 1131 | - | return "", nil | 1108 | + | return authConfig, nil |
| 1132 | } | 1109 | } | ||
| 1133 | 1110 | ||||
| 1134 | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { | 1111 | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { | ||
| @@ -1155,8 +1132,13 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||||
| 1155 | // 2. Secrets backend (pass/sops - encrypted) | 1132 | // 2. Secrets backend (pass/sops - encrypted) | ||
| 1156 | // 3. Config file path (least preferred) | 1133 | // 3. Config file path (least preferred) | ||
| 1157 | 1134 | ||||
| 1135 | + | const minTokenSecretLength = 32 | |||
| 1136 | + | ||||
| 1158 | // Check environment variable first | 1137 | // Check environment variable first | ||
| 1159 | if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" { | 1138 | if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" { | ||
| 1139 | + | if len(envSecret) < minTokenSecretLength { | |||
| 1140 | + | return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret)) | |||
| 1141 | + | } | |||
| 1160 | log.Debug().Msg("using token secret from environment variable") | 1142 | log.Debug().Msg("using token secret from environment variable") | ||
| 1161 | return []byte(envSecret), nil | 1143 | return []byte(envSecret), nil | ||
| 1162 | } | 1144 | } | ||
| @@ -1171,6 +1153,10 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||||
| 1171 | return nil, log.WrapErr(err, "failed to load token secret") | 1153 | return nil, log.WrapErr(err, "failed to load token secret") | ||
| 1172 | } | 1154 | } | ||
| 1173 | 1155 | ||||
| 1156 | + | if len(secret) < minTokenSecretLength { | |||
| 1157 | + | return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret)) | |||
| 1158 | + | } | |||
| 1159 | + | ||||
| 1174 | return []byte(secret), nil | 1160 | return []byte(secret), nil | ||
| 1175 | } | 1161 | } | ||
| 1176 | 1162 | ||||
| @@ -1275,6 +1261,29 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||||
| 1275 | 1261 | ||||
| 1276 | // createContainerService creates the container service with configuration. | 1262 | // createContainerService creates the container service with configuration. | ||
| 1277 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | 1263 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | ||
| 1264 | + | // Parse and validate container resource limits from config | |||
| 1265 | + | if cfg.Containers.CPULimit < 0 { | |||
| 1266 | + | return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit) | |||
| 1267 | + | } | |||
| 1268 | + | if cfg.Containers.PidsLimit < 0 { | |||
| 1269 | + | return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit) | |||
| 1270 | + | } | |||
| 1271 | + | var defaultMemoryLimit int64 | |||
| 1272 | + | if cfg.Containers.MemoryLimit != "" { | |||
| 1273 | + | parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit) | |||
| 1274 | + | if err != nil { | |||
| 1275 | + | return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err) | |||
| 1276 | + | } | |||
| 1277 | + | if parsed <= 0 { | |||
| 1278 | + | return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit) | |||
| 1279 | + | } | |||
| 1280 | + | defaultMemoryLimit = parsed | |||
| 1281 | + | } | |||
| 1282 | + | var defaultNanoCPUs int64 | |||
| 1283 | + | if cfg.Containers.CPULimit > 0 { | |||
| 1284 | + | defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9) | |||
| 1285 | + | } | |||
| 1286 | + | ||||
| 1278 | containerConfig := container.Config{ | 1287 | containerConfig := container.Config{ | ||
| 1279 | RegistryAuthEnabled: cfg.Auth.Enabled, | 1288 | RegistryAuthEnabled: cfg.Auth.Enabled, | ||
| 1280 | RegistryDomain: cfg.Server.RegistryDomain, | 1289 | RegistryDomain: cfg.Server.RegistryDomain, | ||
| @@ -1298,6 +1307,9 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1298 | DrainDelay: v.GetDuration("deploy.drain_delay"), | 1307 | DrainDelay: v.GetDuration("deploy.drain_delay"), | ||
| 1299 | DrainMode: v.GetString("deploy.drain_mode"), | 1308 | DrainMode: v.GetString("deploy.drain_mode"), | ||
| 1300 | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | 1309 | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | ||
| 1310 | + | DefaultMemoryLimit: defaultMemoryLimit, | |||
| 1311 | + | DefaultNanoCPUs: defaultNanoCPUs, | |||
| 1312 | + | DefaultPidsLimit: cfg.Containers.PidsLimit, | |||
| 1301 | } | 1313 | } | ||
| 1302 | if v.IsSet("deploy.drain_delay") { | 1314 | if v.IsSet("deploy.drain_delay") { | ||
| 1303 | containerConfig.DrainDelayConfigured = true | 1315 | containerConfig.DrainDelayConfigured = true | ||
| @@ -1542,7 +1554,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1542 | ) | 1554 | ) | ||
| 1543 | 1555 | ||||
| 1544 | registryMux := http.NewServeMux() | 1556 | registryMux := http.NewServeMux() | ||
| 1545 | - | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | 1557 | + | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log) |
| 1546 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | 1558 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | ||
| 1547 | 1559 | ||||
| 1548 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | 1560 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | ||
| @@ -1675,12 +1687,22 @@ func registerAuthRoutes( | |||||
| 1675 | trustedNets []*net.IPNet, | 1687 | trustedNets []*net.IPNet, | ||
| 1676 | cidrAllowlistMiddleware func(http.Handler) http.Handler, | 1688 | cidrAllowlistMiddleware func(http.Handler) http.Handler, | ||
| 1677 | rateLimitMiddleware func(http.Handler) http.Handler, | 1689 | rateLimitMiddleware func(http.Handler) http.Handler, | ||
| 1690 | + | cfg Config, | |||
| 1678 | log zerowrap.Logger, | 1691 | log zerowrap.Logger, | ||
| 1679 | ) { | 1692 | ) { | ||
| 1680 | if svc.authHandler == nil { | 1693 | if svc.authHandler == nil { | ||
| 1681 | return | 1694 | return | ||
| 1682 | } | 1695 | } | ||
| 1683 | 1696 | ||||
| 1697 | + | // Auth endpoints always get rate limiting, even if global rate limiting is disabled. | |||
| 1698 | + | // This prevents brute-force attacks against password/token endpoints. | |||
| 1699 | + | authRateLimitMiddleware := rateLimitMiddleware | |||
| 1700 | + | if !cfg.API.RateLimit.Enabled { | |||
| 1701 | + | authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log) | |||
| 1702 | + | authIPLimiter := ratelimit.NewMemoryStore(5, 10, log) | |||
| 1703 | + | authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log) | |||
| 1704 | + | } | |||
| 1705 | + | ||||
| 1684 | // Auth endpoints are NOT protected by auth - they're where clients authenticate | 1706 | // Auth endpoints are NOT protected by auth - they're where clients authenticate | ||
| 1685 | // but still need rate limiting to prevent brute force attacks. | 1707 | // but still need rate limiting to prevent brute force attacks. | ||
| 1686 | authMiddlewares := []func(http.Handler) http.Handler{ | 1708 | authMiddlewares := []func(http.Handler) http.Handler{ | ||
| @@ -1691,7 +1713,7 @@ func registerAuthRoutes( | |||||
| 1691 | if cidrAllowlistMiddleware != nil { | 1713 | if cidrAllowlistMiddleware != nil { | ||
| 1692 | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | 1714 | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | ||
| 1693 | } | 1715 | } | ||
| 1694 | - | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | 1716 | + | authMiddlewares = append(authMiddlewares, authRateLimitMiddleware) |
| 1695 | authWithMiddleware := otelhttp.NewHandler( | 1717 | authWithMiddleware := otelhttp.NewHandler( | ||
| 1696 | middleware.Chain(authMiddlewares...)(svc.authHandler), | 1718 | middleware.Chain(authMiddlewares...)(svc.authHandler), | ||
| 1697 | "gordon.auth", | 1719 | "gordon.auth", | ||
| @@ -1774,27 +1796,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1774 | 1796 | ||||
| 1775 | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log) | 1797 | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log) | ||
| 1776 | 1798 | ||||
| 1777 | - | // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can | 1799 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log) |
| 1778 | - | // accept h2c traffic from clients and load balancers (e.g. Cloudflare). | 1800 | + | if err != nil { |
| 1779 | - | var proxyProtos http.Protocols | 1801 | + | return err |
| 1780 | - | proxyProtos.SetHTTP1(true) | |||
| 1781 | - | proxyProtos.SetUnencryptedHTTP2(true) | |||
| 1782 | - | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log) | |||
| 1783 | - | var tlsSrv *http.Server | |||
| 1784 | - | var tlsReady <-chan struct{} | |||
| 1785 | - | if cfg.Server.TLSEnabled { | |||
| 1786 | - | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | |||
| 1787 | - | return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | |||
| 1788 | - | } | |||
| 1789 | - | tlsSrv, tlsReady = startTLSServer( | |||
| 1790 | - | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |||
| 1791 | - | proxyHandler, | |||
| 1792 | - | "proxy-tls", | |||
| 1793 | - | cfg.Server.TLSCertFile, | |||
| 1794 | - | cfg.Server.TLSKeyFile, | |||
| 1795 | - | errChan, | |||
| 1796 | - | log, | |||
| 1797 | - | ) | |||
| 1798 | } | 1802 | } | ||
| 1799 | 1803 | ||||
| 1800 | // Wait for all enabled servers to bind their ports before auto-starting containers. | 1804 | // Wait for all enabled servers to bind their ports before auto-starting containers. | ||
| @@ -2075,6 +2079,52 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||||
| 2075 | log.Info().Msg("Gordon stopped") | 2079 | log.Info().Msg("Gordon stopped") | ||
| 2076 | } | 2080 | } | ||
| 2077 | 2081 | ||||
| 2082 | + | // startProxyServers sets up proxy server(s) depending on TLS configuration. | |||
| 2083 | + | // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic. | |||
| 2084 | + | // Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2. | |||
| 2085 | + | func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |||
| 2086 | + | if !cfg.Server.TLSEnabled { | |||
| 2087 | + | var proxyProtos http.Protocols | |||
| 2088 | + | proxyProtos.SetHTTP1(true) | |||
| 2089 | + | proxyProtos.SetUnencryptedHTTP2(true) | |||
| 2090 | + | srv, ready := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log) | |||
| 2091 | + | return srv, ready, nil, nil, nil | |||
| 2092 | + | } | |||
| 2093 | + | ||||
| 2094 | + | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | |||
| 2095 | + | return nil, nil, nil, nil, fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | |||
| 2096 | + | } | |||
| 2097 | + | ||||
| 2098 | + | tlsPort := cfg.Server.TLSPort | |||
| 2099 | + | redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 2100 | + | host := r.Host | |||
| 2101 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |||
| 2102 | + | host = h | |||
| 2103 | + | } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { | |||
| 2104 | + | host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") | |||
| 2105 | + | } | |||
| 2106 | + | if tlsPort != 443 { | |||
| 2107 | + | host = net.JoinHostPort(host, strconv.Itoa(tlsPort)) | |||
| 2108 | + | } else if strings.Contains(host, ":") { | |||
| 2109 | + | host = "[" + host + "]" | |||
| 2110 | + | } | |||
| 2111 | + | target := "https://" + host + r.RequestURI | |||
| 2112 | + | http.Redirect(w, r, target, http.StatusPermanentRedirect) | |||
| 2113 | + | }) | |||
| 2114 | + | ||||
| 2115 | + | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log) | |||
| 2116 | + | tlsSrv, tlsReady := startTLSServer( | |||
| 2117 | + | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |||
| 2118 | + | proxyHandler, | |||
| 2119 | + | "proxy-tls", | |||
| 2120 | + | cfg.Server.TLSCertFile, | |||
| 2121 | + | cfg.Server.TLSKeyFile, | |||
| 2122 | + | errChan, | |||
| 2123 | + | log, | |||
| 2124 | + | ) | |||
| 2125 | + | return proxySrv, proxyReady, tlsSrv, tlsReady, nil | |||
| 2126 | + | } | |||
| 2127 | + | ||||
| 2078 | // startServer starts an HTTP server, returning the server instance and a channel | 2128 | // startServer starts an HTTP server, returning the server instance and a channel | ||
| 2079 | // that closes once the listening socket is bound. This lets callers wait for the | 2129 | // that closes once the listening socket is bound. This lets callers wait for the | ||
| 2080 | // port to be ready before taking actions that depend on it (e.g. auto-start | 2130 | // port to be ready before taking actions that depend on it (e.g. auto-start | ||
| @@ -2421,8 +2471,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2421 | v.SetDefault("logging.container_logs.max_age", 28) | 2471 | v.SetDefault("logging.container_logs.max_age", 28) | ||
| 2422 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | 2472 | v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty | ||
| 2423 | v.SetDefault("auth.enabled", true) | 2473 | v.SetDefault("auth.enabled", true) | ||
| 2424 | - | // Note: auth.type is intentionally not set - it's inferred from config | 2474 | + | // Note: auth.type defaults to "token" (the only supported mode) |
| 2425 | - | // If password_hash is set -> password mode, otherwise -> token mode | |||
| 2426 | v.SetDefault("auth.secrets_backend", "") | 2475 | v.SetDefault("auth.secrets_backend", "") | ||
| 2427 | v.SetDefault("auth.token_expiry", "720h") | 2476 | v.SetDefault("auth.token_expiry", "720h") | ||
| 2428 | v.SetDefault("api.rate_limit.enabled", true) | 2477 | v.SetDefault("api.rate_limit.enabled", true) | ||
M internal/app/run_auth_config_test.go +34 -27
| @@ -2,59 +2,66 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "strings" | |
| 6 | 5 | "testing" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/gordon/internal/domain" | |
| 9 | 8 | "github.com/bnema/zerowrap" | |
| 10 | 9 | "github.com/spf13/viper" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | |
| 14 | - | t.Setenv(TokenSecretEnvVar, "test-token-secret") | |
| 15 | + | t.Setenv(TokenSecretEnvVar, "test-token-secret-that-is-at-least-32-bytes-long") | |
| 15 | 16 | ||
| 16 | 17 | cfg := Config{} | |
| 17 | 18 | cfg.Auth.Enabled = false | |
| 18 | 19 | cfg.Auth.Type = "token" | |
| 19 | 20 | ||
| 20 | 21 | authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default()) | |
| 21 | - | if err != nil { | |
| 22 | - | t.Fatalf("expected no error, got %v", err) | |
| 23 | - | } | |
| 24 | - | if authCfg.Enabled { | |
| 25 | - | t.Fatalf("expected auth config enabled=false, got true") | |
| 26 | - | } | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | assert.False(t, authCfg.Enabled, "expected auth config enabled=false") | |
| 27 | 24 | } | |
| 28 | 25 | ||
| 29 | 26 | func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) { | |
| 30 | 27 | _, err := resolveSecretsBackend("") | |
| 31 | - | if err == nil { | |
| 32 | - | t.Fatal("expected error for missing secrets backend") | |
| 33 | - | } | |
| 34 | - | if !strings.Contains(err.Error(), "auth.secrets_backend is required") { | |
| 35 | - | t.Fatalf("unexpected error: %v", err) | |
| 36 | - | } | |
| 28 | + | require.Error(t, err) | |
| 29 | + | assert.Contains(t, err.Error(), "auth.secrets_backend is required") | |
| 37 | 30 | } | |
| 38 | 31 | ||
| 39 | 32 | func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) { | |
| 40 | 33 | _, err := resolveSecretsBackend("vault") | |
| 41 | - | if err == nil { | |
| 42 | - | t.Fatal("expected error for unsupported secrets backend") | |
| 43 | - | } | |
| 44 | - | if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) { | |
| 45 | - | t.Fatalf("unexpected error: %v", err) | |
| 46 | - | } | |
| 34 | + | require.Error(t, err) | |
| 35 | + | assert.Contains(t, err.Error(), `unsupported auth.secrets_backend "vault"`) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestResolveAuthType_RejectsPassword(t *testing.T) { | |
| 39 | + | cfg := Config{} | |
| 40 | + | cfg.Auth.Type = "password" | |
| 41 | + | _, err := resolveAuthType(cfg) | |
| 42 | + | require.Error(t, err) | |
| 43 | + | assert.Contains(t, err.Error(), "unsupported auth.type") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestResolveAuthType_AcceptsToken(t *testing.T) { | |
| 47 | + | cfg := Config{} | |
| 48 | + | cfg.Auth.Type = "token" | |
| 49 | + | authType, err := resolveAuthType(cfg) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | assert.Equal(t, domain.AuthTypeToken, authType) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestResolveAuthType_AcceptsEmpty(t *testing.T) { | |
| 55 | + | cfg := Config{} | |
| 56 | + | authType, err := resolveAuthType(cfg) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | assert.Equal(t, domain.AuthTypeToken, authType) | |
| 47 | 59 | } | |
| 48 | 60 | ||
| 49 | 61 | func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | |
| 50 | 62 | v := viper.New() | |
| 51 | 63 | ||
| 52 | 64 | err := loadConfig(v, "") | |
| 53 | - | if err != nil { | |
| 54 | - | t.Fatalf("expected no error, got %v", err) | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | if got := v.GetString("auth.secrets_backend"); got != "" { | |
| 58 | - | t.Fatalf("expected empty default for auth.secrets_backend, got %q", got) | |
| 59 | - | } | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend") | |
| 60 | 67 | } | |
| @@ -2,59 +2,66 @@ package app | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "strings" | |||
| 6 | "testing" | 5 | "testing" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/gordon/internal/domain" | 7 | "github.com/bnema/gordon/internal/domain" | ||
| 9 | "github.com/bnema/zerowrap" | 8 | "github.com/bnema/zerowrap" | ||
| 10 | "github.com/spf13/viper" | 9 | "github.com/spf13/viper" | ||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | 14 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | ||
| 14 | - | t.Setenv(TokenSecretEnvVar, "test-token-secret") | 15 | + | t.Setenv(TokenSecretEnvVar, "test-token-secret-that-is-at-least-32-bytes-long") |
| 15 | 16 | ||||
| 16 | cfg := Config{} | 17 | cfg := Config{} | ||
| 17 | cfg.Auth.Enabled = false | 18 | cfg.Auth.Enabled = false | ||
| 18 | cfg.Auth.Type = "token" | 19 | cfg.Auth.Type = "token" | ||
| 19 | 20 | ||||
| 20 | authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default()) | 21 | authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default()) | ||
| 21 | - | if err != nil { | 22 | + | require.NoError(t, err) |
| 22 | - | t.Fatalf("expected no error, got %v", err) | 23 | + | assert.False(t, authCfg.Enabled, "expected auth config enabled=false") |
| 23 | - | } | |||
| 24 | - | if authCfg.Enabled { | |||
| 25 | - | t.Fatalf("expected auth config enabled=false, got true") | |||
| 26 | - | } | |||
| 27 | } | 24 | } | ||
| 28 | 25 | ||||
| 29 | func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) { | 26 | func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) { | ||
| 30 | _, err := resolveSecretsBackend("") | 27 | _, err := resolveSecretsBackend("") | ||
| 31 | - | if err == nil { | 28 | + | require.Error(t, err) |
| 32 | - | t.Fatal("expected error for missing secrets backend") | 29 | + | assert.Contains(t, err.Error(), "auth.secrets_backend is required") |
| 33 | - | } | |||
| 34 | - | if !strings.Contains(err.Error(), "auth.secrets_backend is required") { | |||
| 35 | - | t.Fatalf("unexpected error: %v", err) | |||
| 36 | - | } | |||
| 37 | } | 30 | } | ||
| 38 | 31 | ||||
| 39 | func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) { | 32 | func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) { | ||
| 40 | _, err := resolveSecretsBackend("vault") | 33 | _, err := resolveSecretsBackend("vault") | ||
| 41 | - | if err == nil { | 34 | + | require.Error(t, err) |
| 42 | - | t.Fatal("expected error for unsupported secrets backend") | 35 | + | assert.Contains(t, err.Error(), `unsupported auth.secrets_backend "vault"`) |
| 43 | - | } | 36 | + | } |
| 44 | - | if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) { | 37 | + | |
| 45 | - | t.Fatalf("unexpected error: %v", err) | 38 | + | func TestResolveAuthType_RejectsPassword(t *testing.T) { |
| 46 | - | } | 39 | + | cfg := Config{} |
| 40 | + | cfg.Auth.Type = "password" | |||
| 41 | + | _, err := resolveAuthType(cfg) | |||
| 42 | + | require.Error(t, err) | |||
| 43 | + | assert.Contains(t, err.Error(), "unsupported auth.type") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestResolveAuthType_AcceptsToken(t *testing.T) { | |||
| 47 | + | cfg := Config{} | |||
| 48 | + | cfg.Auth.Type = "token" | |||
| 49 | + | authType, err := resolveAuthType(cfg) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | assert.Equal(t, domain.AuthTypeToken, authType) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestResolveAuthType_AcceptsEmpty(t *testing.T) { | |||
| 55 | + | cfg := Config{} | |||
| 56 | + | authType, err := resolveAuthType(cfg) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | assert.Equal(t, domain.AuthTypeToken, authType) | |||
| 47 | } | 59 | } | ||
| 48 | 60 | ||||
| 49 | func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | 61 | func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | ||
| 50 | v := viper.New() | 62 | v := viper.New() | ||
| 51 | 63 | ||||
| 52 | err := loadConfig(v, "") | 64 | err := loadConfig(v, "") | ||
| 53 | - | if err != nil { | 65 | + | require.NoError(t, err) |
| 54 | - | t.Fatalf("expected no error, got %v", err) | 66 | + | assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend") |
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | if got := v.GetString("auth.secrets_backend"); got != "" { | |||
| 58 | - | t.Fatalf("expected empty default for auth.secrets_backend, got %q", got) | |||
| 59 | - | } | |||
| 60 | } | 67 | } | ||
M internal/boundaries/in/auth.go +3 -8
| @@ -15,11 +15,6 @@ type AuthService interface { | |||
| 15 | 15 | // IsEnabled returns whether authentication is enabled. | |
| 16 | 16 | IsEnabled() bool | |
| 17 | 17 | ||
| 18 | - | // Password authentication | |
| 19 | - | // ValidatePassword checks if the username and password are valid. | |
| 20 | - | ValidatePassword(ctx context.Context, username, password string) bool | |
| 21 | - | ||
| 22 | - | // Token authentication | |
| 23 | 18 | // ValidateToken validates a JWT token and returns its claims. | |
| 24 | 19 | ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | |
| 25 | 20 | ||
| @@ -40,9 +35,6 @@ type AuthService interface { | |||
| 40 | 35 | // ListTokens returns all stored tokens. | |
| 41 | 36 | ListTokens(ctx context.Context) ([]domain.Token, error) | |
| 42 | 37 | ||
| 43 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |
| 44 | - | GeneratePasswordHash(password string) (string, error) | |
| 45 | - | ||
| 46 | 38 | // GetAuthStatus returns authentication status from context. | |
| 47 | 39 | // Extracts and validates token claims that were set by auth middleware. | |
| 48 | 40 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | |
| @@ -51,4 +43,7 @@ type AuthService interface { | |||
| 51 | 43 | // Returns the same token string if it was already extended within the debounce window (1h). | |
| 52 | 44 | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |
| 53 | 45 | ExtendToken(ctx context.Context, tokenString string) (string, error) | |
| 46 | + | ||
| 47 | + | // GetAccessTokenTTL returns the configured ephemeral access token lifetime. | |
| 48 | + | GetAccessTokenTTL() time.Duration | |
| 54 | 49 | } | |
| @@ -15,11 +15,6 @@ type AuthService interface { | |||||
| 15 | // IsEnabled returns whether authentication is enabled. | 15 | // IsEnabled returns whether authentication is enabled. | ||
| 16 | IsEnabled() bool | 16 | IsEnabled() bool | ||
| 17 | 17 | ||||
| 18 | - | // Password authentication | |||
| 19 | - | // ValidatePassword checks if the username and password are valid. | |||
| 20 | - | ValidatePassword(ctx context.Context, username, password string) bool | |||
| 21 | - | ||||
| 22 | - | // Token authentication | |||
| 23 | // ValidateToken validates a JWT token and returns its claims. | 18 | // ValidateToken validates a JWT token and returns its claims. | ||
| 24 | ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | 19 | ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) | ||
| 25 | 20 | ||||
| @@ -40,9 +35,6 @@ type AuthService interface { | |||||
| 40 | // ListTokens returns all stored tokens. | 35 | // ListTokens returns all stored tokens. | ||
| 41 | ListTokens(ctx context.Context) ([]domain.Token, error) | 36 | ListTokens(ctx context.Context) ([]domain.Token, error) | ||
| 42 | 37 | ||||
| 43 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |||
| 44 | - | GeneratePasswordHash(password string) (string, error) | |||
| 45 | - | ||||
| 46 | // GetAuthStatus returns authentication status from context. | 38 | // GetAuthStatus returns authentication status from context. | ||
| 47 | // Extracts and validates token claims that were set by auth middleware. | 39 | // Extracts and validates token claims that were set by auth middleware. | ||
| 48 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | 40 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | ||
| @@ -51,4 +43,7 @@ type AuthService interface { | |||||
| 51 | // Returns the same token string if it was already extended within the debounce window (1h). | 43 | // Returns the same token string if it was already extended within the debounce window (1h). | ||
| 52 | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | 44 | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | ||
| 53 | ExtendToken(ctx context.Context, tokenString string) (string, error) | 45 | ExtendToken(ctx context.Context, tokenString string) (string, error) | ||
| 46 | + | ||||
| 47 | + | // GetAccessTokenTTL returns the configured ephemeral access token lifetime. | |||
| 48 | + | GetAccessTokenTTL() time.Duration | |||
| 54 | } | 49 | } | ||
M internal/boundaries/in/mocks/mock_auth_service.go +44 -123
| @@ -183,66 +183,6 @@ func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx co | |||
| 183 | 183 | return _c | |
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | - | // GeneratePasswordHash provides a mock function for the type MockAuthService | |
| 187 | - | func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) { | |
| 188 | - | ret := _mock.Called(password) | |
| 189 | - | ||
| 190 | - | if len(ret) == 0 { | |
| 191 | - | panic("no return value specified for GeneratePasswordHash") | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | var r0 string | |
| 195 | - | var r1 error | |
| 196 | - | if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { | |
| 197 | - | return returnFunc(password) | |
| 198 | - | } | |
| 199 | - | if returnFunc, ok := ret.Get(0).(func(string) string); ok { | |
| 200 | - | r0 = returnFunc(password) | |
| 201 | - | } else { | |
| 202 | - | r0 = ret.Get(0).(string) | |
| 203 | - | } | |
| 204 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 205 | - | r1 = returnFunc(password) | |
| 206 | - | } else { | |
| 207 | - | r1 = ret.Error(1) | |
| 208 | - | } | |
| 209 | - | return r0, r1 | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | // MockAuthService_GeneratePasswordHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GeneratePasswordHash' | |
| 213 | - | type MockAuthService_GeneratePasswordHash_Call struct { | |
| 214 | - | *mock.Call | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // GeneratePasswordHash is a helper method to define mock.On call | |
| 218 | - | // - password string | |
| 219 | - | func (_e *MockAuthService_Expecter) GeneratePasswordHash(password interface{}) *MockAuthService_GeneratePasswordHash_Call { | |
| 220 | - | return &MockAuthService_GeneratePasswordHash_Call{Call: _e.mock.On("GeneratePasswordHash", password)} | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Run(run func(password string)) *MockAuthService_GeneratePasswordHash_Call { | |
| 224 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 225 | - | var arg0 string | |
| 226 | - | if args[0] != nil { | |
| 227 | - | arg0 = args[0].(string) | |
| 228 | - | } | |
| 229 | - | run( | |
| 230 | - | arg0, | |
| 231 | - | ) | |
| 232 | - | }) | |
| 233 | - | return _c | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Return(s string, err error) *MockAuthService_GeneratePasswordHash_Call { | |
| 237 | - | _c.Call.Return(s, err) | |
| 238 | - | return _c | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) RunAndReturn(run func(password string) (string, error)) *MockAuthService_GeneratePasswordHash_Call { | |
| 242 | - | _c.Call.Return(run) | |
| 243 | - | return _c | |
| 244 | - | } | |
| 245 | - | ||
| 246 | 186 | // GenerateToken provides a mock function for the type MockAuthService | |
| 247 | 187 | func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | |
| 248 | 188 | ret := _mock.Called(ctx, subject, scopes, expiry) | |
| @@ -321,6 +261,50 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context. | |||
| 321 | 261 | return _c | |
| 322 | 262 | } | |
| 323 | 263 | ||
| 264 | + | // GetAccessTokenTTL provides a mock function for the type MockAuthService | |
| 265 | + | func (_mock *MockAuthService) GetAccessTokenTTL() time.Duration { | |
| 266 | + | ret := _mock.Called() | |
| 267 | + | ||
| 268 | + | if len(ret) == 0 { | |
| 269 | + | panic("no return value specified for GetAccessTokenTTL") | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | var r0 time.Duration | |
| 273 | + | if returnFunc, ok := ret.Get(0).(func() time.Duration); ok { | |
| 274 | + | r0 = returnFunc() | |
| 275 | + | } else { | |
| 276 | + | r0 = ret.Get(0).(time.Duration) | |
| 277 | + | } | |
| 278 | + | return r0 | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // MockAuthService_GetAccessTokenTTL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessTokenTTL' | |
| 282 | + | type MockAuthService_GetAccessTokenTTL_Call struct { | |
| 283 | + | *mock.Call | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // GetAccessTokenTTL is a helper method to define mock.On call | |
| 287 | + | func (_e *MockAuthService_Expecter) GetAccessTokenTTL() *MockAuthService_GetAccessTokenTTL_Call { | |
| 288 | + | return &MockAuthService_GetAccessTokenTTL_Call{Call: _e.mock.On("GetAccessTokenTTL")} | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (_c *MockAuthService_GetAccessTokenTTL_Call) Run(run func()) *MockAuthService_GetAccessTokenTTL_Call { | |
| 292 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 293 | + | run() | |
| 294 | + | }) | |
| 295 | + | return _c | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (_c *MockAuthService_GetAccessTokenTTL_Call) Return(duration time.Duration) *MockAuthService_GetAccessTokenTTL_Call { | |
| 299 | + | _c.Call.Return(duration) | |
| 300 | + | return _c | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func (_c *MockAuthService_GetAccessTokenTTL_Call) RunAndReturn(run func() time.Duration) *MockAuthService_GetAccessTokenTTL_Call { | |
| 304 | + | _c.Call.Return(run) | |
| 305 | + | return _c | |
| 306 | + | } | |
| 307 | + | ||
| 324 | 308 | // GetAuthStatus provides a mock function for the type MockAuthService | |
| 325 | 309 | func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) { | |
| 326 | 310 | ret := _mock.Called(ctx) | |
| @@ -650,69 +634,6 @@ func (_c *MockAuthService_RevokeToken_Call) RunAndReturn(run func(ctx context.Co | |||
| 650 | 634 | return _c | |
| 651 | 635 | } | |
| 652 | 636 | ||
| 653 | - | // ValidatePassword provides a mock function for the type MockAuthService | |
| 654 | - | func (_mock *MockAuthService) ValidatePassword(ctx context.Context, username string, password string) bool { | |
| 655 | - | ret := _mock.Called(ctx, username, password) | |
| 656 | - | ||
| 657 | - | if len(ret) == 0 { | |
| 658 | - | panic("no return value specified for ValidatePassword") | |
| 659 | - | } | |
| 660 | - | ||
| 661 | - | var r0 bool | |
| 662 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { | |
| 663 | - | r0 = returnFunc(ctx, username, password) | |
| 664 | - | } else { | |
| 665 | - | r0 = ret.Get(0).(bool) | |
| 666 | - | } | |
| 667 | - | return r0 | |
| 668 | - | } | |
| 669 | - | ||
| 670 | - | // MockAuthService_ValidatePassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidatePassword' | |
| 671 | - | type MockAuthService_ValidatePassword_Call struct { | |
| 672 | - | *mock.Call | |
| 673 | - | } | |
| 674 | - | ||
| 675 | - | // ValidatePassword is a helper method to define mock.On call | |
| 676 | - | // - ctx context.Context | |
| 677 | - | // - username string | |
| 678 | - | // - password string | |
| 679 | - | func (_e *MockAuthService_Expecter) ValidatePassword(ctx interface{}, username interface{}, password interface{}) *MockAuthService_ValidatePassword_Call { | |
| 680 | - | return &MockAuthService_ValidatePassword_Call{Call: _e.mock.On("ValidatePassword", ctx, username, password)} | |
| 681 | - | } | |
| 682 | - | ||
| 683 | - | func (_c *MockAuthService_ValidatePassword_Call) Run(run func(ctx context.Context, username string, password string)) *MockAuthService_ValidatePassword_Call { | |
| 684 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 685 | - | var arg0 context.Context | |
| 686 | - | if args[0] != nil { | |
| 687 | - | arg0 = args[0].(context.Context) | |
| 688 | - | } | |
| 689 | - | var arg1 string | |
| 690 | - | if args[1] != nil { | |
| 691 | - | arg1 = args[1].(string) | |
| 692 | - | } | |
| 693 | - | var arg2 string | |
| 694 | - | if args[2] != nil { | |
| 695 | - | arg2 = args[2].(string) | |
| 696 | - | } | |
| 697 | - | run( | |
| 698 | - | arg0, | |
| 699 | - | arg1, | |
| 700 | - | arg2, | |
| 701 | - | ) | |
| 702 | - | }) | |
| 703 | - | return _c | |
| 704 | - | } | |
| 705 | - | ||
| 706 | - | func (_c *MockAuthService_ValidatePassword_Call) Return(b bool) *MockAuthService_ValidatePassword_Call { | |
| 707 | - | _c.Call.Return(b) | |
| 708 | - | return _c | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | func (_c *MockAuthService_ValidatePassword_Call) RunAndReturn(run func(ctx context.Context, username string, password string) bool) *MockAuthService_ValidatePassword_Call { | |
| 712 | - | _c.Call.Return(run) | |
| 713 | - | return _c | |
| 714 | - | } | |
| 715 | - | ||
| 716 | 637 | // ValidateToken provides a mock function for the type MockAuthService | |
| 717 | 638 | func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | |
| 718 | 639 | ret := _mock.Called(ctx, tokenString) | |
| @@ -183,66 +183,6 @@ func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx co | |||||
| 183 | return _c | 183 | return _c | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | - | // GeneratePasswordHash provides a mock function for the type MockAuthService | |||
| 187 | - | func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) { | |||
| 188 | - | ret := _mock.Called(password) | |||
| 189 | - | ||||
| 190 | - | if len(ret) == 0 { | |||
| 191 | - | panic("no return value specified for GeneratePasswordHash") | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | var r0 string | |||
| 195 | - | var r1 error | |||
| 196 | - | if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { | |||
| 197 | - | return returnFunc(password) | |||
| 198 | - | } | |||
| 199 | - | if returnFunc, ok := ret.Get(0).(func(string) string); ok { | |||
| 200 | - | r0 = returnFunc(password) | |||
| 201 | - | } else { | |||
| 202 | - | r0 = ret.Get(0).(string) | |||
| 203 | - | } | |||
| 204 | - | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 205 | - | r1 = returnFunc(password) | |||
| 206 | - | } else { | |||
| 207 | - | r1 = ret.Error(1) | |||
| 208 | - | } | |||
| 209 | - | return r0, r1 | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | // MockAuthService_GeneratePasswordHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GeneratePasswordHash' | |||
| 213 | - | type MockAuthService_GeneratePasswordHash_Call struct { | |||
| 214 | - | *mock.Call | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // GeneratePasswordHash is a helper method to define mock.On call | |||
| 218 | - | // - password string | |||
| 219 | - | func (_e *MockAuthService_Expecter) GeneratePasswordHash(password interface{}) *MockAuthService_GeneratePasswordHash_Call { | |||
| 220 | - | return &MockAuthService_GeneratePasswordHash_Call{Call: _e.mock.On("GeneratePasswordHash", password)} | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Run(run func(password string)) *MockAuthService_GeneratePasswordHash_Call { | |||
| 224 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 225 | - | var arg0 string | |||
| 226 | - | if args[0] != nil { | |||
| 227 | - | arg0 = args[0].(string) | |||
| 228 | - | } | |||
| 229 | - | run( | |||
| 230 | - | arg0, | |||
| 231 | - | ) | |||
| 232 | - | }) | |||
| 233 | - | return _c | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) Return(s string, err error) *MockAuthService_GeneratePasswordHash_Call { | |||
| 237 | - | _c.Call.Return(s, err) | |||
| 238 | - | return _c | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | func (_c *MockAuthService_GeneratePasswordHash_Call) RunAndReturn(run func(password string) (string, error)) *MockAuthService_GeneratePasswordHash_Call { | |||
| 242 | - | _c.Call.Return(run) | |||
| 243 | - | return _c | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | // GenerateToken provides a mock function for the type MockAuthService | 186 | // GenerateToken provides a mock function for the type MockAuthService | ||
| 247 | func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | 187 | func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | ||
| 248 | ret := _mock.Called(ctx, subject, scopes, expiry) | 188 | ret := _mock.Called(ctx, subject, scopes, expiry) | ||
| @@ -321,6 +261,50 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context. | |||||
| 321 | return _c | 261 | return _c | ||
| 322 | } | 262 | } | ||
| 323 | 263 | ||||
| 264 | + | // GetAccessTokenTTL provides a mock function for the type MockAuthService | |||
| 265 | + | func (_mock *MockAuthService) GetAccessTokenTTL() time.Duration { | |||
| 266 | + | ret := _mock.Called() | |||
| 267 | + | ||||
| 268 | + | if len(ret) == 0 { | |||
| 269 | + | panic("no return value specified for GetAccessTokenTTL") | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | var r0 time.Duration | |||
| 273 | + | if returnFunc, ok := ret.Get(0).(func() time.Duration); ok { | |||
| 274 | + | r0 = returnFunc() | |||
| 275 | + | } else { | |||
| 276 | + | r0 = ret.Get(0).(time.Duration) | |||
| 277 | + | } | |||
| 278 | + | return r0 | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // MockAuthService_GetAccessTokenTTL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessTokenTTL' | |||
| 282 | + | type MockAuthService_GetAccessTokenTTL_Call struct { | |||
| 283 | + | *mock.Call | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // GetAccessTokenTTL is a helper method to define mock.On call | |||
| 287 | + | func (_e *MockAuthService_Expecter) GetAccessTokenTTL() *MockAuthService_GetAccessTokenTTL_Call { | |||
| 288 | + | return &MockAuthService_GetAccessTokenTTL_Call{Call: _e.mock.On("GetAccessTokenTTL")} | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func (_c *MockAuthService_GetAccessTokenTTL_Call) Run(run func()) *MockAuthService_GetAccessTokenTTL_Call { | |||
| 292 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 293 | + | run() | |||
| 294 | + | }) | |||
| 295 | + | return _c | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (_c *MockAuthService_GetAccessTokenTTL_Call) Return(duration time.Duration) *MockAuthService_GetAccessTokenTTL_Call { | |||
| 299 | + | _c.Call.Return(duration) | |||
| 300 | + | return _c | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func (_c *MockAuthService_GetAccessTokenTTL_Call) RunAndReturn(run func() time.Duration) *MockAuthService_GetAccessTokenTTL_Call { | |||
| 304 | + | _c.Call.Return(run) | |||
| 305 | + | return _c | |||
| 306 | + | } | |||
| 307 | + | ||||
| 324 | // GetAuthStatus provides a mock function for the type MockAuthService | 308 | // GetAuthStatus provides a mock function for the type MockAuthService | ||
| 325 | func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) { | 309 | func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) { | ||
| 326 | ret := _mock.Called(ctx) | 310 | ret := _mock.Called(ctx) | ||
| @@ -650,69 +634,6 @@ func (_c *MockAuthService_RevokeToken_Call) RunAndReturn(run func(ctx context.Co | |||||
| 650 | return _c | 634 | return _c | ||
| 651 | } | 635 | } | ||
| 652 | 636 | ||||
| 653 | - | // ValidatePassword provides a mock function for the type MockAuthService | |||
| 654 | - | func (_mock *MockAuthService) ValidatePassword(ctx context.Context, username string, password string) bool { | |||
| 655 | - | ret := _mock.Called(ctx, username, password) | |||
| 656 | - | ||||
| 657 | - | if len(ret) == 0 { | |||
| 658 | - | panic("no return value specified for ValidatePassword") | |||
| 659 | - | } | |||
| 660 | - | ||||
| 661 | - | var r0 bool | |||
| 662 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { | |||
| 663 | - | r0 = returnFunc(ctx, username, password) | |||
| 664 | - | } else { | |||
| 665 | - | r0 = ret.Get(0).(bool) | |||
| 666 | - | } | |||
| 667 | - | return r0 | |||
| 668 | - | } | |||
| 669 | - | ||||
| 670 | - | // MockAuthService_ValidatePassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidatePassword' | |||
| 671 | - | type MockAuthService_ValidatePassword_Call struct { | |||
| 672 | - | *mock.Call | |||
| 673 | - | } | |||
| 674 | - | ||||
| 675 | - | // ValidatePassword is a helper method to define mock.On call | |||
| 676 | - | // - ctx context.Context | |||
| 677 | - | // - username string | |||
| 678 | - | // - password string | |||
| 679 | - | func (_e *MockAuthService_Expecter) ValidatePassword(ctx interface{}, username interface{}, password interface{}) *MockAuthService_ValidatePassword_Call { | |||
| 680 | - | return &MockAuthService_ValidatePassword_Call{Call: _e.mock.On("ValidatePassword", ctx, username, password)} | |||
| 681 | - | } | |||
| 682 | - | ||||
| 683 | - | func (_c *MockAuthService_ValidatePassword_Call) Run(run func(ctx context.Context, username string, password string)) *MockAuthService_ValidatePassword_Call { | |||
| 684 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 685 | - | var arg0 context.Context | |||
| 686 | - | if args[0] != nil { | |||
| 687 | - | arg0 = args[0].(context.Context) | |||
| 688 | - | } | |||
| 689 | - | var arg1 string | |||
| 690 | - | if args[1] != nil { | |||
| 691 | - | arg1 = args[1].(string) | |||
| 692 | - | } | |||
| 693 | - | var arg2 string | |||
| 694 | - | if args[2] != nil { | |||
| 695 | - | arg2 = args[2].(string) | |||
| 696 | - | } | |||
| 697 | - | run( | |||
| 698 | - | arg0, | |||
| 699 | - | arg1, | |||
| 700 | - | arg2, | |||
| 701 | - | ) | |||
| 702 | - | }) | |||
| 703 | - | return _c | |||
| 704 | - | } | |||
| 705 | - | ||||
| 706 | - | func (_c *MockAuthService_ValidatePassword_Call) Return(b bool) *MockAuthService_ValidatePassword_Call { | |||
| 707 | - | _c.Call.Return(b) | |||
| 708 | - | return _c | |||
| 709 | - | } | |||
| 710 | - | ||||
| 711 | - | func (_c *MockAuthService_ValidatePassword_Call) RunAndReturn(run func(ctx context.Context, username string, password string) bool) *MockAuthService_ValidatePassword_Call { | |||
| 712 | - | _c.Call.Return(run) | |||
| 713 | - | return _c | |||
| 714 | - | } | |||
| 715 | - | ||||
| 716 | // ValidateToken provides a mock function for the type MockAuthService | 637 | // ValidateToken provides a mock function for the type MockAuthService | ||
| 717 | func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | 638 | func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | ||
| 718 | ret := _mock.Called(ctx, tokenString) | 639 | ret := _mock.Called(ctx, tokenString) | ||
M internal/domain/auth.go +40 -6
| @@ -12,8 +12,6 @@ import ( | |||
| 12 | 12 | type AuthType string | |
| 13 | 13 | ||
| 14 | 14 | const ( | |
| 15 | - | // AuthTypePassword uses bcrypt-hashed password authentication. | |
| 16 | - | AuthTypePassword AuthType = "password" | |
| 17 | 15 | // AuthTypeToken uses JWT token-based authentication. | |
| 18 | 16 | AuthTypeToken AuthType = "token" | |
| 19 | 17 | ) | |
| @@ -220,21 +218,57 @@ func (s *AdminScope) String() string { | |||
| 220 | 218 | ||
| 221 | 219 | // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action. | |
| 222 | 220 | func HasAdminAccess(scopes []string, resource, action string) bool { | |
| 223 | - | for _, scopeStr := range scopes { | |
| 221 | + | return ScopesGrantAdminAccess(scopes, resource, action) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // ScopesGrantRegistryAccess reports whether any of the granted scope strings | |
| 225 | + | // authorise the given action on the named repository. | |
| 226 | + | // It handles simple shorthand scopes ("pull", "push", "*") for backwards | |
| 227 | + | // compatibility as well as full Docker v2 format scopes (repository:name:actions). | |
| 228 | + | func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) bool { | |
| 229 | + | for _, raw := range grantedScopes { | |
| 230 | + | scopeStr := strings.TrimSpace(raw) | |
| 231 | + | ||
| 232 | + | // Shorthand scopes: "*", "pull", "push" | |
| 233 | + | switch scopeStr { | |
| 234 | + | case ScopeActionAll: | |
| 235 | + | return true | |
| 236 | + | case ScopeActionPull, ScopeActionPush: | |
| 237 | + | if scopeStr == action { | |
| 238 | + | return true | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // Full Docker v2 format: repository:name:actions | |
| 224 | 243 | scope, err := ParseScope(scopeStr) | |
| 225 | 244 | if err != nil { | |
| 226 | 245 | continue | |
| 227 | 246 | } | |
| 228 | - | ||
| 229 | - | if !scope.IsAdminScope() { | |
| 247 | + | if scope.Type != ScopeTypeRepository { | |
| 230 | 248 | continue | |
| 231 | 249 | } | |
| 250 | + | if scope.CanAccess(repoName, action) { | |
| 251 | + | return true | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return false | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // ScopesGrantAdminAccess reports whether any of the granted scope strings | |
| 258 | + | // authorise the given action on the named admin resource. | |
| 259 | + | // It handles the wildcard shorthand ("*") as well as full admin scope format | |
| 260 | + | // (admin:resource:actions). | |
| 261 | + | func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool { | |
| 262 | + | for _, raw := range grantedScopes { | |
| 263 | + | scopeStr := strings.TrimSpace(raw) | |
| 264 | + | if scopeStr == ScopeActionAll { | |
| 265 | + | return true | |
| 266 | + | } | |
| 232 | 267 | ||
| 233 | 268 | adminScope, err := ParseAdminScope(scopeStr) | |
| 234 | 269 | if err != nil { | |
| 235 | 270 | continue | |
| 236 | 271 | } | |
| 237 | - | ||
| 238 | 272 | if adminScope.CanAccess(resource, action) { | |
| 239 | 273 | return true | |
| 240 | 274 | } | |
| @@ -12,8 +12,6 @@ import ( | |||||
| 12 | type AuthType string | 12 | type AuthType string | ||
| 13 | 13 | ||||
| 14 | const ( | 14 | const ( | ||
| 15 | - | // AuthTypePassword uses bcrypt-hashed password authentication. | |||
| 16 | - | AuthTypePassword AuthType = "password" | |||
| 17 | // AuthTypeToken uses JWT token-based authentication. | 15 | // AuthTypeToken uses JWT token-based authentication. | ||
| 18 | AuthTypeToken AuthType = "token" | 16 | AuthTypeToken AuthType = "token" | ||
| 19 | ) | 17 | ) | ||
| @@ -220,21 +218,57 @@ func (s *AdminScope) String() string { | |||||
| 220 | 218 | ||||
| 221 | // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action. | 219 | // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action. | ||
| 222 | func HasAdminAccess(scopes []string, resource, action string) bool { | 220 | func HasAdminAccess(scopes []string, resource, action string) bool { | ||
| 223 | - | for _, scopeStr := range scopes { | 221 | + | return ScopesGrantAdminAccess(scopes, resource, action) |
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // ScopesGrantRegistryAccess reports whether any of the granted scope strings | |||
| 225 | + | // authorise the given action on the named repository. | |||
| 226 | + | // It handles simple shorthand scopes ("pull", "push", "*") for backwards | |||
| 227 | + | // compatibility as well as full Docker v2 format scopes (repository:name:actions). | |||
| 228 | + | func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) bool { | |||
| 229 | + | for _, raw := range grantedScopes { | |||
| 230 | + | scopeStr := strings.TrimSpace(raw) | |||
| 231 | + | ||||
| 232 | + | // Shorthand scopes: "*", "pull", "push" | |||
| 233 | + | switch scopeStr { | |||
| 234 | + | case ScopeActionAll: | |||
| 235 | + | return true | |||
| 236 | + | case ScopeActionPull, ScopeActionPush: | |||
| 237 | + | if scopeStr == action { | |||
| 238 | + | return true | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // Full Docker v2 format: repository:name:actions | |||
| 224 | scope, err := ParseScope(scopeStr) | 243 | scope, err := ParseScope(scopeStr) | ||
| 225 | if err != nil { | 244 | if err != nil { | ||
| 226 | continue | 245 | continue | ||
| 227 | } | 246 | } | ||
| 228 | - | 247 | + | if scope.Type != ScopeTypeRepository { | |
| 229 | - | if !scope.IsAdminScope() { | |||
| 230 | continue | 248 | continue | ||
| 231 | } | 249 | } | ||
| 250 | + | if scope.CanAccess(repoName, action) { | |||
| 251 | + | return true | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | return false | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // ScopesGrantAdminAccess reports whether any of the granted scope strings | |||
| 258 | + | // authorise the given action on the named admin resource. | |||
| 259 | + | // It handles the wildcard shorthand ("*") as well as full admin scope format | |||
| 260 | + | // (admin:resource:actions). | |||
| 261 | + | func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool { | |||
| 262 | + | for _, raw := range grantedScopes { | |||
| 263 | + | scopeStr := strings.TrimSpace(raw) | |||
| 264 | + | if scopeStr == ScopeActionAll { | |||
| 265 | + | return true | |||
| 266 | + | } | |||
| 232 | 267 | ||||
| 233 | adminScope, err := ParseAdminScope(scopeStr) | 268 | adminScope, err := ParseAdminScope(scopeStr) | ||
| 234 | if err != nil { | 269 | if err != nil { | ||
| 235 | continue | 270 | continue | ||
| 236 | } | 271 | } | ||
| 237 | - | ||||
| 238 | if adminScope.CanAccess(resource, action) { | 272 | if adminScope.CanAccess(resource, action) { | ||
| 239 | return true | 273 | return true | ||
| 240 | } | 274 | } | ||
M internal/domain/auth_test.go +197 -0
| @@ -325,6 +325,203 @@ func TestScope_String(t *testing.T) { | |||
| 325 | 325 | } | |
| 326 | 326 | } | |
| 327 | 327 | ||
| 328 | + | func TestScopesGrantRegistryAccess(t *testing.T) { | |
| 329 | + | tests := []struct { | |
| 330 | + | name string | |
| 331 | + | granted []string | |
| 332 | + | repoName string | |
| 333 | + | action string | |
| 334 | + | want bool | |
| 335 | + | }{ | |
| 336 | + | { | |
| 337 | + | name: "wildcard shorthand grants any action", | |
| 338 | + | granted: []string{"*"}, | |
| 339 | + | repoName: "myrepo", | |
| 340 | + | action: "push", | |
| 341 | + | want: true, | |
| 342 | + | }, | |
| 343 | + | { | |
| 344 | + | name: "pull shorthand grants pull", | |
| 345 | + | granted: []string{"pull"}, | |
| 346 | + | repoName: "myrepo", | |
| 347 | + | action: "pull", | |
| 348 | + | want: true, | |
| 349 | + | }, | |
| 350 | + | { | |
| 351 | + | name: "pull shorthand does not grant push", | |
| 352 | + | granted: []string{"pull"}, | |
| 353 | + | repoName: "myrepo", | |
| 354 | + | action: "push", | |
| 355 | + | want: false, | |
| 356 | + | }, | |
| 357 | + | { | |
| 358 | + | name: "push shorthand grants push", | |
| 359 | + | granted: []string{"push"}, | |
| 360 | + | repoName: "myrepo", | |
| 361 | + | action: "push", | |
| 362 | + | want: true, | |
| 363 | + | }, | |
| 364 | + | { | |
| 365 | + | name: "full scope grants matching repo and action", | |
| 366 | + | granted: []string{"repository:myrepo:push,pull"}, | |
| 367 | + | repoName: "myrepo", | |
| 368 | + | action: "pull", | |
| 369 | + | want: true, | |
| 370 | + | }, | |
| 371 | + | { | |
| 372 | + | name: "full scope does not grant different repo", | |
| 373 | + | granted: []string{"repository:myrepo:push,pull"}, | |
| 374 | + | repoName: "other", | |
| 375 | + | action: "pull", | |
| 376 | + | want: false, | |
| 377 | + | }, | |
| 378 | + | { | |
| 379 | + | name: "wildcard repo scope grants any repo", | |
| 380 | + | granted: []string{"repository:*:pull"}, | |
| 381 | + | repoName: "anything", | |
| 382 | + | action: "pull", | |
| 383 | + | want: true, | |
| 384 | + | }, | |
| 385 | + | { | |
| 386 | + | name: "org wildcard scope grants repo in org", | |
| 387 | + | granted: []string{"repository:myorg/*:push"}, | |
| 388 | + | repoName: "myorg/app", | |
| 389 | + | action: "push", | |
| 390 | + | want: true, | |
| 391 | + | }, | |
| 392 | + | { | |
| 393 | + | name: "org wildcard scope rejects repo outside org", | |
| 394 | + | granted: []string{"repository:myorg/*:push"}, | |
| 395 | + | repoName: "other/app", | |
| 396 | + | action: "push", | |
| 397 | + | want: false, | |
| 398 | + | }, | |
| 399 | + | { | |
| 400 | + | name: "admin scope is ignored for registry access", | |
| 401 | + | granted: []string{"admin:routes:read"}, | |
| 402 | + | repoName: "myrepo", | |
| 403 | + | action: "pull", | |
| 404 | + | want: false, | |
| 405 | + | }, | |
| 406 | + | { | |
| 407 | + | name: "no granted scopes", | |
| 408 | + | granted: []string{}, | |
| 409 | + | repoName: "myrepo", | |
| 410 | + | action: "pull", | |
| 411 | + | want: false, | |
| 412 | + | }, | |
| 413 | + | { | |
| 414 | + | name: "whitespace around scope is trimmed", | |
| 415 | + | granted: []string{" repository:myrepo:pull "}, | |
| 416 | + | repoName: "myrepo", | |
| 417 | + | action: "pull", | |
| 418 | + | want: true, | |
| 419 | + | }, | |
| 420 | + | { | |
| 421 | + | name: "invalid scope string is skipped", | |
| 422 | + | granted: []string{"garbage", "repository:myrepo:pull"}, | |
| 423 | + | repoName: "myrepo", | |
| 424 | + | action: "pull", | |
| 425 | + | want: true, | |
| 426 | + | }, | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | for _, tt := range tests { | |
| 430 | + | t.Run(tt.name, func(t *testing.T) { | |
| 431 | + | got := ScopesGrantRegistryAccess(tt.granted, tt.repoName, tt.action) | |
| 432 | + | assert.Equal(t, tt.want, got) | |
| 433 | + | }) | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func TestScopesGrantAdminAccess(t *testing.T) { | |
| 438 | + | tests := []struct { | |
| 439 | + | name string | |
| 440 | + | granted []string | |
| 441 | + | resource string | |
| 442 | + | action string | |
| 443 | + | want bool | |
| 444 | + | }{ | |
| 445 | + | { | |
| 446 | + | name: "wildcard shorthand grants any admin access", | |
| 447 | + | granted: []string{"*"}, | |
| 448 | + | resource: "routes", | |
| 449 | + | action: "read", | |
| 450 | + | want: true, | |
| 451 | + | }, | |
| 452 | + | { | |
| 453 | + | name: "exact admin scope grants matching resource and action", | |
| 454 | + | granted: []string{"admin:routes:read"}, | |
| 455 | + | resource: "routes", | |
| 456 | + | action: "read", | |
| 457 | + | want: true, | |
| 458 | + | }, | |
| 459 | + | { | |
| 460 | + | name: "exact admin scope denies wrong action", | |
| 461 | + | granted: []string{"admin:routes:read"}, | |
| 462 | + | resource: "routes", | |
| 463 | + | action: "write", | |
| 464 | + | want: false, | |
| 465 | + | }, | |
| 466 | + | { | |
| 467 | + | name: "exact admin scope denies wrong resource", | |
| 468 | + | granted: []string{"admin:routes:read"}, | |
| 469 | + | resource: "secrets", | |
| 470 | + | action: "read", | |
| 471 | + | want: false, | |
| 472 | + | }, | |
| 473 | + | { | |
| 474 | + | name: "admin wildcard resource grants any resource", | |
| 475 | + | granted: []string{"admin:*:read"}, | |
| 476 | + | resource: "secrets", | |
| 477 | + | action: "read", | |
| 478 | + | want: true, | |
| 479 | + | }, | |
| 480 | + | { | |
| 481 | + | name: "admin wildcard action grants any action", | |
| 482 | + | granted: []string{"admin:routes:*"}, | |
| 483 | + | resource: "routes", | |
| 484 | + | action: "write", | |
| 485 | + | want: true, | |
| 486 | + | }, | |
| 487 | + | { | |
| 488 | + | name: "full admin wildcard grants everything", | |
| 489 | + | granted: []string{"admin:*:*"}, | |
| 490 | + | resource: "config", | |
| 491 | + | action: "write", | |
| 492 | + | want: true, | |
| 493 | + | }, | |
| 494 | + | { | |
| 495 | + | name: "repository scope is ignored for admin access", | |
| 496 | + | granted: []string{"repository:myrepo:push"}, | |
| 497 | + | resource: "routes", | |
| 498 | + | action: "read", | |
| 499 | + | want: false, | |
| 500 | + | }, | |
| 501 | + | { | |
| 502 | + | name: "no granted scopes", | |
| 503 | + | granted: []string{}, | |
| 504 | + | resource: "routes", | |
| 505 | + | action: "read", | |
| 506 | + | want: false, | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | name: "whitespace around scope is trimmed", | |
| 510 | + | granted: []string{" admin:routes:read "}, | |
| 511 | + | resource: "routes", | |
| 512 | + | action: "read", | |
| 513 | + | want: true, | |
| 514 | + | }, | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | for _, tt := range tests { | |
| 518 | + | t.Run(tt.name, func(t *testing.T) { | |
| 519 | + | got := ScopesGrantAdminAccess(tt.granted, tt.resource, tt.action) | |
| 520 | + | assert.Equal(t, tt.want, got) | |
| 521 | + | }) | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 328 | 525 | func TestParseScope_RoundTrip(t *testing.T) { | |
| 329 | 526 | // Test that parsing and stringifying produces the same result | |
| 330 | 527 | inputs := []string{ | |
| @@ -325,6 +325,203 @@ func TestScope_String(t *testing.T) { | |||||
| 325 | } | 325 | } | ||
| 326 | } | 326 | } | ||
| 327 | 327 | ||||
| 328 | + | func TestScopesGrantRegistryAccess(t *testing.T) { | |||
| 329 | + | tests := []struct { | |||
| 330 | + | name string | |||
| 331 | + | granted []string | |||
| 332 | + | repoName string | |||
| 333 | + | action string | |||
| 334 | + | want bool | |||
| 335 | + | }{ | |||
| 336 | + | { | |||
| 337 | + | name: "wildcard shorthand grants any action", | |||
| 338 | + | granted: []string{"*"}, | |||
| 339 | + | repoName: "myrepo", | |||
| 340 | + | action: "push", | |||
| 341 | + | want: true, | |||
| 342 | + | }, | |||
| 343 | + | { | |||
| 344 | + | name: "pull shorthand grants pull", | |||
| 345 | + | granted: []string{"pull"}, | |||
| 346 | + | repoName: "myrepo", | |||
| 347 | + | action: "pull", | |||
| 348 | + | want: true, | |||
| 349 | + | }, | |||
| 350 | + | { | |||
| 351 | + | name: "pull shorthand does not grant push", | |||
| 352 | + | granted: []string{"pull"}, | |||
| 353 | + | repoName: "myrepo", | |||
| 354 | + | action: "push", | |||
| 355 | + | want: false, | |||
| 356 | + | }, | |||
| 357 | + | { | |||
| 358 | + | name: "push shorthand grants push", | |||
| 359 | + | granted: []string{"push"}, | |||
| 360 | + | repoName: "myrepo", | |||
| 361 | + | action: "push", | |||
| 362 | + | want: true, | |||
| 363 | + | }, | |||
| 364 | + | { | |||
| 365 | + | name: "full scope grants matching repo and action", | |||
| 366 | + | granted: []string{"repository:myrepo:push,pull"}, | |||
| 367 | + | repoName: "myrepo", | |||
| 368 | + | action: "pull", | |||
| 369 | + | want: true, | |||
| 370 | + | }, | |||
| 371 | + | { | |||
| 372 | + | name: "full scope does not grant different repo", | |||
| 373 | + | granted: []string{"repository:myrepo:push,pull"}, | |||
| 374 | + | repoName: "other", | |||
| 375 | + | action: "pull", | |||
| 376 | + | want: false, | |||
| 377 | + | }, | |||
| 378 | + | { | |||
| 379 | + | name: "wildcard repo scope grants any repo", | |||
| 380 | + | granted: []string{"repository:*:pull"}, | |||
| 381 | + | repoName: "anything", | |||
| 382 | + | action: "pull", | |||
| 383 | + | want: true, | |||
| 384 | + | }, | |||
| 385 | + | { | |||
| 386 | + | name: "org wildcard scope grants repo in org", | |||
| 387 | + | granted: []string{"repository:myorg/*:push"}, | |||
| 388 | + | repoName: "myorg/app", | |||
| 389 | + | action: "push", | |||
| 390 | + | want: true, | |||
| 391 | + | }, | |||
| 392 | + | { | |||
| 393 | + | name: "org wildcard scope rejects repo outside org", | |||
| 394 | + | granted: []string{"repository:myorg/*:push"}, | |||
| 395 | + | repoName: "other/app", | |||
| 396 | + | action: "push", | |||
| 397 | + | want: false, | |||
| 398 | + | }, | |||
| 399 | + | { | |||
| 400 | + | name: "admin scope is ignored for registry access", | |||
| 401 | + | granted: []string{"admin:routes:read"}, | |||
| 402 | + | repoName: "myrepo", | |||
| 403 | + | action: "pull", | |||
| 404 | + | want: false, | |||
| 405 | + | }, | |||
| 406 | + | { | |||
| 407 | + | name: "no granted scopes", | |||
| 408 | + | granted: []string{}, | |||
| 409 | + | repoName: "myrepo", | |||
| 410 | + | action: "pull", | |||
| 411 | + | want: false, | |||
| 412 | + | }, | |||
| 413 | + | { | |||
| 414 | + | name: "whitespace around scope is trimmed", | |||
| 415 | + | granted: []string{" repository:myrepo:pull "}, | |||
| 416 | + | repoName: "myrepo", | |||
| 417 | + | action: "pull", | |||
| 418 | + | want: true, | |||
| 419 | + | }, | |||
| 420 | + | { | |||
| 421 | + | name: "invalid scope string is skipped", | |||
| 422 | + | granted: []string{"garbage", "repository:myrepo:pull"}, | |||
| 423 | + | repoName: "myrepo", | |||
| 424 | + | action: "pull", | |||
| 425 | + | want: true, | |||
| 426 | + | }, | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | for _, tt := range tests { | |||
| 430 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 431 | + | got := ScopesGrantRegistryAccess(tt.granted, tt.repoName, tt.action) | |||
| 432 | + | assert.Equal(t, tt.want, got) | |||
| 433 | + | }) | |||
| 434 | + | } | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func TestScopesGrantAdminAccess(t *testing.T) { | |||
| 438 | + | tests := []struct { | |||
| 439 | + | name string | |||
| 440 | + | granted []string | |||
| 441 | + | resource string | |||
| 442 | + | action string | |||
| 443 | + | want bool | |||
| 444 | + | }{ | |||
| 445 | + | { | |||
| 446 | + | name: "wildcard shorthand grants any admin access", | |||
| 447 | + | granted: []string{"*"}, | |||
| 448 | + | resource: "routes", | |||
| 449 | + | action: "read", | |||
| 450 | + | want: true, | |||
| 451 | + | }, | |||
| 452 | + | { | |||
| 453 | + | name: "exact admin scope grants matching resource and action", | |||
| 454 | + | granted: []string{"admin:routes:read"}, | |||
| 455 | + | resource: "routes", | |||
| 456 | + | action: "read", | |||
| 457 | + | want: true, | |||
| 458 | + | }, | |||
| 459 | + | { | |||
| 460 | + | name: "exact admin scope denies wrong action", | |||
| 461 | + | granted: []string{"admin:routes:read"}, | |||
| 462 | + | resource: "routes", | |||
| 463 | + | action: "write", | |||
| 464 | + | want: false, | |||
| 465 | + | }, | |||
| 466 | + | { | |||
| 467 | + | name: "exact admin scope denies wrong resource", | |||
| 468 | + | granted: []string{"admin:routes:read"}, | |||
| 469 | + | resource: "secrets", | |||
| 470 | + | action: "read", | |||
| 471 | + | want: false, | |||
| 472 | + | }, | |||
| 473 | + | { | |||
| 474 | + | name: "admin wildcard resource grants any resource", | |||
| 475 | + | granted: []string{"admin:*:read"}, | |||
| 476 | + | resource: "secrets", | |||
| 477 | + | action: "read", | |||
| 478 | + | want: true, | |||
| 479 | + | }, | |||
| 480 | + | { | |||
| 481 | + | name: "admin wildcard action grants any action", | |||
| 482 | + | granted: []string{"admin:routes:*"}, | |||
| 483 | + | resource: "routes", | |||
| 484 | + | action: "write", | |||
| 485 | + | want: true, | |||
| 486 | + | }, | |||
| 487 | + | { | |||
| 488 | + | name: "full admin wildcard grants everything", | |||
| 489 | + | granted: []string{"admin:*:*"}, | |||
| 490 | + | resource: "config", | |||
| 491 | + | action: "write", | |||
| 492 | + | want: true, | |||
| 493 | + | }, | |||
| 494 | + | { | |||
| 495 | + | name: "repository scope is ignored for admin access", | |||
| 496 | + | granted: []string{"repository:myrepo:push"}, | |||
| 497 | + | resource: "routes", | |||
| 498 | + | action: "read", | |||
| 499 | + | want: false, | |||
| 500 | + | }, | |||
| 501 | + | { | |||
| 502 | + | name: "no granted scopes", | |||
| 503 | + | granted: []string{}, | |||
| 504 | + | resource: "routes", | |||
| 505 | + | action: "read", | |||
| 506 | + | want: false, | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | name: "whitespace around scope is trimmed", | |||
| 510 | + | granted: []string{" admin:routes:read "}, | |||
| 511 | + | resource: "routes", | |||
| 512 | + | action: "read", | |||
| 513 | + | want: true, | |||
| 514 | + | }, | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | for _, tt := range tests { | |||
| 518 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 519 | + | got := ScopesGrantAdminAccess(tt.granted, tt.resource, tt.action) | |||
| 520 | + | assert.Equal(t, tt.want, got) | |||
| 521 | + | }) | |||
| 522 | + | } | |||
| 523 | + | } | |||
| 524 | + | ||||
| 328 | func TestParseScope_RoundTrip(t *testing.T) { | 525 | func TestParseScope_RoundTrip(t *testing.T) { | ||
| 329 | // Test that parsing and stringifying produces the same result | 526 | // Test that parsing and stringifying produces the same result | ||
| 330 | inputs := []string{ | 527 | inputs := []string{ | ||
M internal/domain/container.go +3 -0
| @@ -58,6 +58,9 @@ type ContainerConfig struct { | |||
| 58 | 58 | NetworkMode string // Network to join | |
| 59 | 59 | Hostname string // Container hostname for DNS | |
| 60 | 60 | Aliases []string // Additional network aliases | |
| 61 | + | MemoryLimit int64 // Memory limit in bytes (0 = no limit) | |
| 62 | + | NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit) | |
| 63 | + | PidsLimit int64 // Max number of PIDs (0 = no limit) | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | // ContainerStatus represents the current state of a container. | |
| @@ -58,6 +58,9 @@ type ContainerConfig struct { | |||||
| 58 | NetworkMode string // Network to join | 58 | NetworkMode string // Network to join | ||
| 59 | Hostname string // Container hostname for DNS | 59 | Hostname string // Container hostname for DNS | ||
| 60 | Aliases []string // Additional network aliases | 60 | Aliases []string // Additional network aliases | ||
| 61 | + | MemoryLimit int64 // Memory limit in bytes (0 = no limit) | |||
| 62 | + | NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit) | |||
| 63 | + | PidsLimit int64 // Max number of PIDs (0 = no limit) | |||
| 61 | } | 64 | } | ||
| 62 | 65 | ||||
| 63 | // ContainerStatus represents the current state of a container. | 66 | // ContainerStatus represents the current state of a container. | ||
M internal/usecase/auth/service.go +14 -42
| @@ -3,7 +3,6 @@ package auth | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | - | "crypto/subtle" | |
| 7 | 6 | "errors" | |
| 8 | 7 | "fmt" | |
| 9 | 8 | "time" | |
| @@ -11,7 +10,6 @@ import ( | |||
| 11 | 10 | "github.com/bnema/zerowrap" | |
| 12 | 11 | "github.com/golang-jwt/jwt/v5" | |
| 13 | 12 | "github.com/google/uuid" | |
| 14 | - | "golang.org/x/crypto/bcrypt" | |
| 15 | 13 | ||
| 16 | 14 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | 15 | "github.com/bnema/gordon/internal/domain" | |
| @@ -20,11 +18,11 @@ import ( | |||
| 20 | 18 | const ( | |
| 21 | 19 | // TokenIssuer is the issuer claim for generated tokens. | |
| 22 | 20 | TokenIssuer = "gordon-registry" | |
| 23 | - | // DefaultBcryptCost is the default cost for bcrypt hashing. | |
| 24 | - | DefaultBcryptCost = 12 | |
| 21 | + | // DefaultAccessTokenTTL is the default lifetime for ephemeral access tokens. | |
| 22 | + | DefaultAccessTokenTTL = 15 * time.Minute | |
| 25 | 23 | // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens. | |
| 26 | 24 | // Tokens with this lifetime or less skip store validation. | |
| 27 | - | MaxAccessTokenLifetime = 5 * time.Minute | |
| 25 | + | MaxAccessTokenLifetime = 1 * time.Hour | |
| 28 | 26 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | |
| 29 | 27 | maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second) | |
| 30 | 28 | // tokenExtensionTTL is the amount of time added to a token's expiry when extended. | |
| @@ -38,12 +36,12 @@ const ( | |||
| 38 | 36 | ||
| 39 | 37 | // Config holds the authentication configuration. | |
| 40 | 38 | type Config struct { | |
| 41 | - | Enabled bool | |
| 42 | - | AuthType domain.AuthType | |
| 43 | - | Username string | |
| 44 | - | PasswordHash string // bcrypt hash for password auth | |
| 45 | - | TokenSecret []byte // signing secret for token auth | |
| 46 | - | TokenExpiry time.Duration // default token expiry (0 = never) | |
| 39 | + | Enabled bool | |
| 40 | + | AuthType domain.AuthType | |
| 41 | + | Username string | |
| 42 | + | TokenSecret []byte // signing secret for token auth | |
| 43 | + | TokenExpiry time.Duration // default token expiry (0 = never) | |
| 44 | + | AccessTokenTTL time.Duration // ephemeral token lifetime (default: 15m) | |
| 47 | 45 | } | |
| 48 | 46 | ||
| 49 | 47 | // Service implements the AuthService interface. | |
| @@ -72,29 +70,12 @@ func (s *Service) IsEnabled() bool { | |||
| 72 | 70 | return s.config.Enabled | |
| 73 | 71 | } | |
| 74 | 72 | ||
| 75 | - | // ValidatePassword checks if the username and password are valid. | |
| 76 | - | func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool { | |
| 77 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 78 | - | zerowrap.FieldLayer: "usecase", | |
| 79 | - | zerowrap.FieldUseCase: "ValidatePassword", | |
| 80 | - | "username": username, | |
| 81 | - | }) | |
| 82 | - | log := zerowrap.FromCtx(ctx) | |
| 83 | - | ||
| 84 | - | // Constant-time username comparison | |
| 85 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1 | |
| 86 | - | ||
| 87 | - | // Bcrypt comparison (already constant-time) | |
| 88 | - | err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password)) | |
| 89 | - | passwordMatch := err == nil | |
| 90 | - | ||
| 91 | - | if !usernameMatch || !passwordMatch { | |
| 92 | - | log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed") | |
| 93 | - | return false | |
| 73 | + | // GetAccessTokenTTL returns the configured ephemeral access token lifetime. | |
| 74 | + | func (s *Service) GetAccessTokenTTL() time.Duration { | |
| 75 | + | if s.config.AccessTokenTTL > 0 { | |
| 76 | + | return s.config.AccessTokenTTL | |
| 94 | 77 | } | |
| 95 | - | ||
| 96 | - | log.Debug().Msg("password validation successful") | |
| 97 | - | return true | |
| 78 | + | return DefaultAccessTokenTTL | |
| 98 | 79 | } | |
| 99 | 80 | ||
| 100 | 81 | // ValidateToken validates a JWT token and returns its claims. | |
| @@ -560,15 +541,6 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||
| 560 | 541 | return newTokenString, nil | |
| 561 | 542 | } | |
| 562 | 543 | ||
| 563 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |
| 564 | - | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |
| 565 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |
| 566 | - | if err != nil { | |
| 567 | - | return "", err | |
| 568 | - | } | |
| 569 | - | return string(hash), nil | |
| 570 | - | } | |
| 571 | - | ||
| 572 | 544 | // getStringClaim safely extracts a string claim from JWT claims. | |
| 573 | 545 | func getStringClaim(claims jwt.MapClaims, key string) string { | |
| 574 | 546 | if val, ok := claims[key].(string); ok { | |
| @@ -3,7 +3,6 @@ package auth | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | - | "crypto/subtle" | |||
| 7 | "errors" | 6 | "errors" | ||
| 8 | "fmt" | 7 | "fmt" | ||
| 9 | "time" | 8 | "time" | ||
| @@ -11,7 +10,6 @@ import ( | |||||
| 11 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 12 | "github.com/golang-jwt/jwt/v5" | 11 | "github.com/golang-jwt/jwt/v5" | ||
| 13 | "github.com/google/uuid" | 12 | "github.com/google/uuid" | ||
| 14 | - | "golang.org/x/crypto/bcrypt" | |||
| 15 | 13 | ||||
| 16 | "github.com/bnema/gordon/internal/boundaries/out" | 14 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -20,11 +18,11 @@ import ( | |||||
| 20 | const ( | 18 | const ( | ||
| 21 | // TokenIssuer is the issuer claim for generated tokens. | 19 | // TokenIssuer is the issuer claim for generated tokens. | ||
| 22 | TokenIssuer = "gordon-registry" | 20 | TokenIssuer = "gordon-registry" | ||
| 23 | - | // DefaultBcryptCost is the default cost for bcrypt hashing. | 21 | + | // DefaultAccessTokenTTL is the default lifetime for ephemeral access tokens. |
| 24 | - | DefaultBcryptCost = 12 | 22 | + | DefaultAccessTokenTTL = 15 * time.Minute |
| 25 | // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens. | 23 | // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens. | ||
| 26 | // Tokens with this lifetime or less skip store validation. | 24 | // Tokens with this lifetime or less skip store validation. | ||
| 27 | - | MaxAccessTokenLifetime = 5 * time.Minute | 25 | + | MaxAccessTokenLifetime = 1 * time.Hour |
| 28 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | 26 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | ||
| 29 | maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second) | 27 | maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second) | ||
| 30 | // tokenExtensionTTL is the amount of time added to a token's expiry when extended. | 28 | // tokenExtensionTTL is the amount of time added to a token's expiry when extended. | ||
| @@ -38,12 +36,12 @@ const ( | |||||
| 38 | 36 | ||||
| 39 | // Config holds the authentication configuration. | 37 | // Config holds the authentication configuration. | ||
| 40 | type Config struct { | 38 | type Config struct { | ||
| 41 | - | Enabled bool | 39 | + | Enabled bool |
| 42 | - | AuthType domain.AuthType | 40 | + | AuthType domain.AuthType |
| 43 | - | Username string | 41 | + | Username string |
| 44 | - | PasswordHash string // bcrypt hash for password auth | 42 | + | TokenSecret []byte // signing secret for token auth |
| 45 | - | TokenSecret []byte // signing secret for token auth | 43 | + | TokenExpiry time.Duration // default token expiry (0 = never) |
| 46 | - | TokenExpiry time.Duration // default token expiry (0 = never) | 44 | + | AccessTokenTTL time.Duration // ephemeral token lifetime (default: 15m) |
| 47 | } | 45 | } | ||
| 48 | 46 | ||||
| 49 | // Service implements the AuthService interface. | 47 | // Service implements the AuthService interface. | ||
| @@ -72,29 +70,12 @@ func (s *Service) IsEnabled() bool { | |||||
| 72 | return s.config.Enabled | 70 | return s.config.Enabled | ||
| 73 | } | 71 | } | ||
| 74 | 72 | ||||
| 75 | - | // ValidatePassword checks if the username and password are valid. | 73 | + | // GetAccessTokenTTL returns the configured ephemeral access token lifetime. |
| 76 | - | func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool { | 74 | + | func (s *Service) GetAccessTokenTTL() time.Duration { |
| 77 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 75 | + | if s.config.AccessTokenTTL > 0 { |
| 78 | - | zerowrap.FieldLayer: "usecase", | 76 | + | return s.config.AccessTokenTTL |
| 79 | - | zerowrap.FieldUseCase: "ValidatePassword", | |||
| 80 | - | "username": username, | |||
| 81 | - | }) | |||
| 82 | - | log := zerowrap.FromCtx(ctx) | |||
| 83 | - | ||||
| 84 | - | // Constant-time username comparison | |||
| 85 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1 | |||
| 86 | - | ||||
| 87 | - | // Bcrypt comparison (already constant-time) | |||
| 88 | - | err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password)) | |||
| 89 | - | passwordMatch := err == nil | |||
| 90 | - | ||||
| 91 | - | if !usernameMatch || !passwordMatch { | |||
| 92 | - | log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed") | |||
| 93 | - | return false | |||
| 94 | } | 77 | } | ||
| 95 | - | 78 | + | return DefaultAccessTokenTTL | |
| 96 | - | log.Debug().Msg("password validation successful") | |||
| 97 | - | return true | |||
| 98 | } | 79 | } | ||
| 99 | 80 | ||||
| 100 | // ValidateToken validates a JWT token and returns its claims. | 81 | // ValidateToken validates a JWT token and returns its claims. | ||
| @@ -560,15 +541,6 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, | |||||
| 560 | return newTokenString, nil | 541 | return newTokenString, nil | ||
| 561 | } | 542 | } | ||
| 562 | 543 | ||||
| 563 | - | // GeneratePasswordHash generates a bcrypt hash for a password. | |||
| 564 | - | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |||
| 565 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |||
| 566 | - | if err != nil { | |||
| 567 | - | return "", err | |||
| 568 | - | } | |||
| 569 | - | return string(hash), nil | |||
| 570 | - | } | |||
| 571 | - | ||||
| 572 | // getStringClaim safely extracts a string claim from JWT claims. | 544 | // getStringClaim safely extracts a string claim from JWT claims. | ||
| 573 | func getStringClaim(claims jwt.MapClaims, key string) string { | 545 | func getStringClaim(claims jwt.MapClaims, key string) string { | ||
| 574 | if val, ok := claims[key].(string); ok { | 546 | if val, ok := claims[key].(string); ok { | ||
M internal/usecase/auth/service_test.go +9 -95
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | - | "golang.org/x/crypto/bcrypt" | |
| 15 | 14 | ||
| 16 | 15 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 17 | 16 | "github.com/bnema/gordon/internal/domain" | |
| @@ -26,7 +25,6 @@ func TestService_GetAuthType(t *testing.T) { | |||
| 26 | 25 | name string | |
| 27 | 26 | authType domain.AuthType | |
| 28 | 27 | }{ | |
| 29 | - | {"password type", domain.AuthTypePassword}, | |
| 30 | 28 | {"token type", domain.AuthTypeToken}, | |
| 31 | 29 | } | |
| 32 | 30 | ||
| @@ -55,53 +53,6 @@ func TestService_IsEnabled(t *testing.T) { | |||
| 55 | 53 | } | |
| 56 | 54 | } | |
| 57 | 55 | ||
| 58 | - | func TestService_ValidatePassword_Success(t *testing.T) { | |
| 59 | - | password := "testpassword123" | |
| 60 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |
| 61 | - | require.NoError(t, err) | |
| 62 | - | ||
| 63 | - | svc := NewService(Config{ | |
| 64 | - | Enabled: true, | |
| 65 | - | AuthType: domain.AuthTypePassword, | |
| 66 | - | Username: "testuser", | |
| 67 | - | PasswordHash: string(hash), | |
| 68 | - | }, nil, zerowrap.Default()) | |
| 69 | - | ||
| 70 | - | ctx := testContext() | |
| 71 | - | assert.True(t, svc.ValidatePassword(ctx, "testuser", password)) | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | func TestService_ValidatePassword_WrongPassword(t *testing.T) { | |
| 75 | - | hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost) | |
| 76 | - | require.NoError(t, err) | |
| 77 | - | ||
| 78 | - | svc := NewService(Config{ | |
| 79 | - | Enabled: true, | |
| 80 | - | AuthType: domain.AuthTypePassword, | |
| 81 | - | Username: "testuser", | |
| 82 | - | PasswordHash: string(hash), | |
| 83 | - | }, nil, zerowrap.Default()) | |
| 84 | - | ||
| 85 | - | ctx := testContext() | |
| 86 | - | assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword")) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func TestService_ValidatePassword_WrongUsername(t *testing.T) { | |
| 90 | - | password := "testpassword123" | |
| 91 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |
| 92 | - | require.NoError(t, err) | |
| 93 | - | ||
| 94 | - | svc := NewService(Config{ | |
| 95 | - | Enabled: true, | |
| 96 | - | AuthType: domain.AuthTypePassword, | |
| 97 | - | Username: "testuser", | |
| 98 | - | PasswordHash: string(hash), | |
| 99 | - | }, nil, zerowrap.Default()) | |
| 100 | - | ||
| 101 | - | ctx := testContext() | |
| 102 | - | assert.False(t, svc.ValidatePassword(ctx, "wronguser", password)) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | 56 | func TestService_GenerateToken_Success(t *testing.T) { | |
| 106 | 57 | tokenStore := mocks.NewMockTokenStore(t) | |
| 107 | 58 | ||
| @@ -229,13 +180,13 @@ func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) { | |||
| 229 | 180 | assert.Contains(t, err.Error(), "expiry must be positive") | |
| 230 | 181 | ||
| 231 | 182 | // Expiry exceeding max should be rejected | |
| 232 | - | _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute) | |
| 183 | + | _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, MaxAccessTokenLifetime+time.Minute) | |
| 233 | 184 | assert.Error(t, err) | |
| 234 | 185 | assert.Contains(t, err.Error(), "exceeds maximum") | |
| 235 | 186 | } | |
| 236 | 187 | ||
| 237 | 188 | func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) { | |
| 238 | - | // Tokens with expiry > 5 minutes must exist in store | |
| 189 | + | // Tokens with expiry > MaxAccessTokenLifetime (1h) must exist in store | |
| 239 | 190 | tokenStore := mocks.NewMockTokenStore(t) | |
| 240 | 191 | ||
| 241 | 192 | svc := NewService(Config{ | |
| @@ -255,7 +206,7 @@ func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) { | |||
| 255 | 206 | }). | |
| 256 | 207 | Return(nil) | |
| 257 | 208 | ||
| 258 | - | token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour) | |
| 209 | + | token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) | |
| 259 | 210 | assert.NoError(t, err) | |
| 260 | 211 | ||
| 261 | 212 | // Validation should call GetToken and IsRevoked | |
| @@ -332,7 +283,7 @@ func TestService_ValidateToken_Success(t *testing.T) { | |||
| 332 | 283 | Return(nil) | |
| 333 | 284 | ||
| 334 | 285 | ctx := testContext() | |
| 335 | - | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |
| 286 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) | |
| 336 | 287 | require.NoError(t, err) | |
| 337 | 288 | ||
| 338 | 289 | // Expect GetToken to be called to verify token exists | |
| @@ -390,7 +341,7 @@ func TestService_ValidateToken_Revoked(t *testing.T) { | |||
| 390 | 341 | Return(nil) | |
| 391 | 342 | ||
| 392 | 343 | ctx := testContext() | |
| 393 | - | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | |
| 344 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) | |
| 394 | 345 | require.NoError(t, err) | |
| 395 | 346 | ||
| 396 | 347 | // Expect GetToken to be called to verify token exists | |
| @@ -720,43 +671,6 @@ func TestService_ListTokens(t *testing.T) { | |||
| 720 | 671 | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | |
| 721 | 672 | } | |
| 722 | 673 | ||
| 723 | - | func TestService_GeneratePasswordHash(t *testing.T) { | |
| 724 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |
| 725 | - | ||
| 726 | - | password := "securePassword123!" | |
| 727 | - | hash, err := svc.GeneratePasswordHash(password) | |
| 728 | - | ||
| 729 | - | assert.NoError(t, err) | |
| 730 | - | assert.NotEmpty(t, hash) | |
| 731 | - | assert.NotEqual(t, password, hash, "hash should not equal plain password") | |
| 732 | - | ||
| 733 | - | // Verify the hash works with bcrypt | |
| 734 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) | |
| 735 | - | assert.NoError(t, err) | |
| 736 | - | ||
| 737 | - | // Verify wrong password fails | |
| 738 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword")) | |
| 739 | - | assert.Error(t, err) | |
| 740 | - | } | |
| 741 | - | ||
| 742 | - | func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) { | |
| 743 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |
| 744 | - | ||
| 745 | - | password := "testpassword" | |
| 746 | - | hash1, err := svc.GeneratePasswordHash(password) | |
| 747 | - | require.NoError(t, err) | |
| 748 | - | ||
| 749 | - | hash2, err := svc.GeneratePasswordHash(password) | |
| 750 | - | require.NoError(t, err) | |
| 751 | - | ||
| 752 | - | // Bcrypt should generate different hashes each time due to salt | |
| 753 | - | assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes") | |
| 754 | - | ||
| 755 | - | // But both should validate correctly | |
| 756 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password))) | |
| 757 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password))) | |
| 758 | - | } | |
| 759 | - | ||
| 760 | 674 | func TestService_GenerateToken_HasNbfClaim(t *testing.T) { | |
| 761 | 675 | // Test that generated tokens include the nbf (not-before) claim | |
| 762 | 676 | // This is a security requirement per SEC-MED-006 | |
| @@ -829,7 +743,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||
| 829 | 743 | ||
| 830 | 744 | ctx := testContext() | |
| 831 | 745 | ||
| 832 | - | // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp | |
| 746 | + | // Generate a token with a short remaining life (MaxAccessTokenLifetime+1m) so ExtendToken produces a different exp | |
| 833 | 747 | var capturedToken *domain.Token | |
| 834 | 748 | tokenStore.EXPECT(). | |
| 835 | 749 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| @@ -838,7 +752,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||
| 838 | 752 | }). | |
| 839 | 753 | Return(nil) | |
| 840 | 754 | ||
| 841 | - | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | |
| 755 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute) | |
| 842 | 756 | require.NoError(t, err) | |
| 843 | 757 | ||
| 844 | 758 | // ExtendToken flow: | |
| @@ -901,7 +815,7 @@ func TestExtendTokenDebounce(t *testing.T) { | |||
| 901 | 815 | ||
| 902 | 816 | ctx := testContext() | |
| 903 | 817 | ||
| 904 | - | // Generate a short-lived token (1h) so the first extension produces a different JWT | |
| 818 | + | // Generate a short-lived token (MaxAccessTokenLifetime+1m) so the first extension produces a different JWT | |
| 905 | 819 | var capturedToken *domain.Token | |
| 906 | 820 | tokenStore.EXPECT(). | |
| 907 | 821 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| @@ -910,7 +824,7 @@ func TestExtendTokenDebounce(t *testing.T) { | |||
| 910 | 824 | }). | |
| 911 | 825 | Return(nil) | |
| 912 | 826 | ||
| 913 | - | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | |
| 827 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute) | |
| 914 | 828 | require.NoError(t, err) | |
| 915 | 829 | ||
| 916 | 830 | // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry | |
| @@ -11,7 +11,6 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | - | "golang.org/x/crypto/bcrypt" | |||
| 15 | 14 | ||||
| 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 15 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -26,7 +25,6 @@ func TestService_GetAuthType(t *testing.T) { | |||||
| 26 | name string | 25 | name string | ||
| 27 | authType domain.AuthType | 26 | authType domain.AuthType | ||
| 28 | }{ | 27 | }{ | ||
| 29 | - | {"password type", domain.AuthTypePassword}, | |||
| 30 | {"token type", domain.AuthTypeToken}, | 28 | {"token type", domain.AuthTypeToken}, | ||
| 31 | } | 29 | } | ||
| 32 | 30 | ||||
| @@ -55,53 +53,6 @@ func TestService_IsEnabled(t *testing.T) { | |||||
| 55 | } | 53 | } | ||
| 56 | } | 54 | } | ||
| 57 | 55 | ||||
| 58 | - | func TestService_ValidatePassword_Success(t *testing.T) { | |||
| 59 | - | password := "testpassword123" | |||
| 60 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |||
| 61 | - | require.NoError(t, err) | |||
| 62 | - | ||||
| 63 | - | svc := NewService(Config{ | |||
| 64 | - | Enabled: true, | |||
| 65 | - | AuthType: domain.AuthTypePassword, | |||
| 66 | - | Username: "testuser", | |||
| 67 | - | PasswordHash: string(hash), | |||
| 68 | - | }, nil, zerowrap.Default()) | |||
| 69 | - | ||||
| 70 | - | ctx := testContext() | |||
| 71 | - | assert.True(t, svc.ValidatePassword(ctx, "testuser", password)) | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | func TestService_ValidatePassword_WrongPassword(t *testing.T) { | |||
| 75 | - | hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost) | |||
| 76 | - | require.NoError(t, err) | |||
| 77 | - | ||||
| 78 | - | svc := NewService(Config{ | |||
| 79 | - | Enabled: true, | |||
| 80 | - | AuthType: domain.AuthTypePassword, | |||
| 81 | - | Username: "testuser", | |||
| 82 | - | PasswordHash: string(hash), | |||
| 83 | - | }, nil, zerowrap.Default()) | |||
| 84 | - | ||||
| 85 | - | ctx := testContext() | |||
| 86 | - | assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword")) | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | func TestService_ValidatePassword_WrongUsername(t *testing.T) { | |||
| 90 | - | password := "testpassword123" | |||
| 91 | - | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |||
| 92 | - | require.NoError(t, err) | |||
| 93 | - | ||||
| 94 | - | svc := NewService(Config{ | |||
| 95 | - | Enabled: true, | |||
| 96 | - | AuthType: domain.AuthTypePassword, | |||
| 97 | - | Username: "testuser", | |||
| 98 | - | PasswordHash: string(hash), | |||
| 99 | - | }, nil, zerowrap.Default()) | |||
| 100 | - | ||||
| 101 | - | ctx := testContext() | |||
| 102 | - | assert.False(t, svc.ValidatePassword(ctx, "wronguser", password)) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | func TestService_GenerateToken_Success(t *testing.T) { | 56 | func TestService_GenerateToken_Success(t *testing.T) { | ||
| 106 | tokenStore := mocks.NewMockTokenStore(t) | 57 | tokenStore := mocks.NewMockTokenStore(t) | ||
| 107 | 58 | ||||
| @@ -229,13 +180,13 @@ func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) { | |||||
| 229 | assert.Contains(t, err.Error(), "expiry must be positive") | 180 | assert.Contains(t, err.Error(), "expiry must be positive") | ||
| 230 | 181 | ||||
| 231 | // Expiry exceeding max should be rejected | 182 | // Expiry exceeding max should be rejected | ||
| 232 | - | _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute) | 183 | + | _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, MaxAccessTokenLifetime+time.Minute) |
| 233 | assert.Error(t, err) | 184 | assert.Error(t, err) | ||
| 234 | assert.Contains(t, err.Error(), "exceeds maximum") | 185 | assert.Contains(t, err.Error(), "exceeds maximum") | ||
| 235 | } | 186 | } | ||
| 236 | 187 | ||||
| 237 | func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) { | 188 | func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) { | ||
| 238 | - | // Tokens with expiry > 5 minutes must exist in store | 189 | + | // Tokens with expiry > MaxAccessTokenLifetime (1h) must exist in store |
| 239 | tokenStore := mocks.NewMockTokenStore(t) | 190 | tokenStore := mocks.NewMockTokenStore(t) | ||
| 240 | 191 | ||||
| 241 | svc := NewService(Config{ | 192 | svc := NewService(Config{ | ||
| @@ -255,7 +206,7 @@ func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) { | |||||
| 255 | }). | 206 | }). | ||
| 256 | Return(nil) | 207 | Return(nil) | ||
| 257 | 208 | ||||
| 258 | - | token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour) | 209 | + | token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) |
| 259 | assert.NoError(t, err) | 210 | assert.NoError(t, err) | ||
| 260 | 211 | ||||
| 261 | // Validation should call GetToken and IsRevoked | 212 | // Validation should call GetToken and IsRevoked | ||
| @@ -332,7 +283,7 @@ func TestService_ValidateToken_Success(t *testing.T) { | |||||
| 332 | Return(nil) | 283 | Return(nil) | ||
| 333 | 284 | ||||
| 334 | ctx := testContext() | 285 | ctx := testContext() | ||
| 335 | - | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | 286 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) |
| 336 | require.NoError(t, err) | 287 | require.NoError(t, err) | ||
| 337 | 288 | ||||
| 338 | // Expect GetToken to be called to verify token exists | 289 | // Expect GetToken to be called to verify token exists | ||
| @@ -390,7 +341,7 @@ func TestService_ValidateToken_Revoked(t *testing.T) { | |||||
| 390 | Return(nil) | 341 | Return(nil) | ||
| 391 | 342 | ||||
| 392 | ctx := testContext() | 343 | ctx := testContext() | ||
| 393 | - | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour) | 344 | + | tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute) |
| 394 | require.NoError(t, err) | 345 | require.NoError(t, err) | ||
| 395 | 346 | ||||
| 396 | // Expect GetToken to be called to verify token exists | 347 | // Expect GetToken to be called to verify token exists | ||
| @@ -720,43 +671,6 @@ func TestService_ListTokens(t *testing.T) { | |||||
| 720 | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | 671 | assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire") | ||
| 721 | } | 672 | } | ||
| 722 | 673 | ||||
| 723 | - | func TestService_GeneratePasswordHash(t *testing.T) { | |||
| 724 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |||
| 725 | - | ||||
| 726 | - | password := "securePassword123!" | |||
| 727 | - | hash, err := svc.GeneratePasswordHash(password) | |||
| 728 | - | ||||
| 729 | - | assert.NoError(t, err) | |||
| 730 | - | assert.NotEmpty(t, hash) | |||
| 731 | - | assert.NotEqual(t, password, hash, "hash should not equal plain password") | |||
| 732 | - | ||||
| 733 | - | // Verify the hash works with bcrypt | |||
| 734 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) | |||
| 735 | - | assert.NoError(t, err) | |||
| 736 | - | ||||
| 737 | - | // Verify wrong password fails | |||
| 738 | - | err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword")) | |||
| 739 | - | assert.Error(t, err) | |||
| 740 | - | } | |||
| 741 | - | ||||
| 742 | - | func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) { | |||
| 743 | - | svc := NewService(Config{}, nil, zerowrap.Default()) | |||
| 744 | - | ||||
| 745 | - | password := "testpassword" | |||
| 746 | - | hash1, err := svc.GeneratePasswordHash(password) | |||
| 747 | - | require.NoError(t, err) | |||
| 748 | - | ||||
| 749 | - | hash2, err := svc.GeneratePasswordHash(password) | |||
| 750 | - | require.NoError(t, err) | |||
| 751 | - | ||||
| 752 | - | // Bcrypt should generate different hashes each time due to salt | |||
| 753 | - | assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes") | |||
| 754 | - | ||||
| 755 | - | // But both should validate correctly | |||
| 756 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password))) | |||
| 757 | - | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password))) | |||
| 758 | - | } | |||
| 759 | - | ||||
| 760 | func TestService_GenerateToken_HasNbfClaim(t *testing.T) { | 674 | func TestService_GenerateToken_HasNbfClaim(t *testing.T) { | ||
| 761 | // Test that generated tokens include the nbf (not-before) claim | 675 | // Test that generated tokens include the nbf (not-before) claim | ||
| 762 | // This is a security requirement per SEC-MED-006 | 676 | // This is a security requirement per SEC-MED-006 | ||
| @@ -829,7 +743,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||||
| 829 | 743 | ||||
| 830 | ctx := testContext() | 744 | ctx := testContext() | ||
| 831 | 745 | ||||
| 832 | - | // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp | 746 | + | // Generate a token with a short remaining life (MaxAccessTokenLifetime+1m) so ExtendToken produces a different exp |
| 833 | var capturedToken *domain.Token | 747 | var capturedToken *domain.Token | ||
| 834 | tokenStore.EXPECT(). | 748 | tokenStore.EXPECT(). | ||
| 835 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | 749 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | ||
| @@ -838,7 +752,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||||
| 838 | }). | 752 | }). | ||
| 839 | Return(nil) | 753 | Return(nil) | ||
| 840 | 754 | ||||
| 841 | - | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | 755 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute) |
| 842 | require.NoError(t, err) | 756 | require.NoError(t, err) | ||
| 843 | 757 | ||||
| 844 | // ExtendToken flow: | 758 | // ExtendToken flow: | ||
| @@ -901,7 +815,7 @@ func TestExtendTokenDebounce(t *testing.T) { | |||||
| 901 | 815 | ||||
| 902 | ctx := testContext() | 816 | ctx := testContext() | ||
| 903 | 817 | ||||
| 904 | - | // Generate a short-lived token (1h) so the first extension produces a different JWT | 818 | + | // Generate a short-lived token (MaxAccessTokenLifetime+1m) so the first extension produces a different JWT |
| 905 | var capturedToken *domain.Token | 819 | var capturedToken *domain.Token | ||
| 906 | tokenStore.EXPECT(). | 820 | tokenStore.EXPECT(). | ||
| 907 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | 821 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | ||
| @@ -910,7 +824,7 @@ func TestExtendTokenDebounce(t *testing.T) { | |||||
| 910 | }). | 824 | }). | ||
| 911 | Return(nil) | 825 | Return(nil) | ||
| 912 | 826 | ||||
| 913 | - | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | 827 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute) |
| 914 | require.NoError(t, err) | 828 | require.NoError(t, err) | ||
| 915 | 829 | ||||
| 916 | // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry | 830 | // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry | ||
M internal/usecase/auto/labels.go +6 -1
| @@ -105,10 +105,15 @@ func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.Blo | |||
| 105 | 105 | } | |
| 106 | 106 | defer reader.Close() | |
| 107 | 107 | ||
| 108 | - | configData, err := io.ReadAll(reader) | |
| 108 | + | const maxConfigBlobSize = 1 << 20 // 1 MB | |
| 109 | + | limited := io.LimitReader(reader, maxConfigBlobSize+1) | |
| 110 | + | configData, err := io.ReadAll(limited) | |
| 109 | 111 | if err != nil { | |
| 110 | 112 | return nil, fmt.Errorf("failed to read config blob: %w", err) | |
| 111 | 113 | } | |
| 114 | + | if len(configData) > maxConfigBlobSize { | |
| 115 | + | return nil, fmt.Errorf("config blob exceeds maximum size of %d bytes", maxConfigBlobSize) | |
| 116 | + | } | |
| 112 | 117 | ||
| 113 | 118 | labels, err := ParseImageLabels(configData) | |
| 114 | 119 | if err != nil { | |
| @@ -105,10 +105,15 @@ func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.Blo | |||||
| 105 | } | 105 | } | ||
| 106 | defer reader.Close() | 106 | defer reader.Close() | ||
| 107 | 107 | ||||
| 108 | - | configData, err := io.ReadAll(reader) | 108 | + | const maxConfigBlobSize = 1 << 20 // 1 MB |
| 109 | + | limited := io.LimitReader(reader, maxConfigBlobSize+1) | |||
| 110 | + | configData, err := io.ReadAll(limited) | |||
| 109 | if err != nil { | 111 | if err != nil { | ||
| 110 | return nil, fmt.Errorf("failed to read config blob: %w", err) | 112 | return nil, fmt.Errorf("failed to read config blob: %w", err) | ||
| 111 | } | 113 | } | ||
| 114 | + | if len(configData) > maxConfigBlobSize { | |||
| 115 | + | return nil, fmt.Errorf("config blob exceeds maximum size of %d bytes", maxConfigBlobSize) | |||
| 116 | + | } | |||
| 112 | 117 | ||||
| 113 | labels, err := ParseImageLabels(configData) | 118 | labels, err := ParseImageLabels(configData) | ||
| 114 | if err != nil { | 119 | if err != nil { | ||
M internal/usecase/container/service.go +17 -0
| @@ -51,6 +51,9 @@ type Config struct { | |||
| 51 | 51 | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | |
| 52 | 52 | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | |
| 53 | 53 | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | |
| 54 | + | DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit) | |
| 55 | + | DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit) | |
| 56 | + | DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit) | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | var tracer = otel.Tracer("gordon.container") | |
| @@ -229,6 +232,10 @@ type containerConfigInput struct { | |||
| 229 | 232 | } | |
| 230 | 233 | ||
| 231 | 234 | func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig { | |
| 235 | + | s.mu.RLock() | |
| 236 | + | cfg := s.config | |
| 237 | + | s.mu.RUnlock() | |
| 238 | + | ||
| 232 | 239 | containerName := s.deploymentContainerName(in.Domain, in.Existing) | |
| 233 | 240 | labels := map[string]string{ | |
| 234 | 241 | domain.LabelDomain: in.Domain, | |
| @@ -266,6 +273,9 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe | |||
| 266 | 273 | Hostname: in.Domain, | |
| 267 | 274 | Labels: labels, | |
| 268 | 275 | AutoRemove: false, | |
| 276 | + | MemoryLimit: cfg.DefaultMemoryLimit, | |
| 277 | + | NanoCPUs: cfg.DefaultNanoCPUs, | |
| 278 | + | PidsLimit: cfg.DefaultPidsLimit, | |
| 269 | 279 | } | |
| 270 | 280 | } | |
| 271 | 281 | ||
| @@ -2232,6 +2242,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2232 | 2242 | } | |
| 2233 | 2243 | envHash := hashEnvironment(envVars) | |
| 2234 | 2244 | ||
| 2245 | + | s.mu.RLock() | |
| 2246 | + | cfg := s.config | |
| 2247 | + | s.mu.RUnlock() | |
| 2248 | + | ||
| 2235 | 2249 | // Create container with attachment labels (use actual ref, track original in labels) | |
| 2236 | 2250 | config := &domain.ContainerConfig{ | |
| 2237 | 2251 | Image: actualImageRef, | |
| @@ -2248,6 +2262,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2248 | 2262 | domain.LabelEnvHash: envHash, | |
| 2249 | 2263 | domain.LabelImage: serviceImage, | |
| 2250 | 2264 | }, | |
| 2265 | + | MemoryLimit: cfg.DefaultMemoryLimit, | |
| 2266 | + | NanoCPUs: cfg.DefaultNanoCPUs, | |
| 2267 | + | PidsLimit: cfg.DefaultPidsLimit, | |
| 2251 | 2268 | } | |
| 2252 | 2269 | ||
| 2253 | 2270 | container, err := s.runtime.CreateContainer(ctx, config) | |
| @@ -51,6 +51,9 @@ type Config struct { | |||||
| 51 | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | 51 | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | ||
| 52 | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | 52 | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | ||
| 53 | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | 53 | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | ||
| 54 | + | DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit) | |||
| 55 | + | DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit) | |||
| 56 | + | DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit) | |||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 56 | var tracer = otel.Tracer("gordon.container") | 59 | var tracer = otel.Tracer("gordon.container") | ||
| @@ -229,6 +232,10 @@ type containerConfigInput struct { | |||||
| 229 | } | 232 | } | ||
| 230 | 233 | ||||
| 231 | func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig { | 234 | func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig { | ||
| 235 | + | s.mu.RLock() | |||
| 236 | + | cfg := s.config | |||
| 237 | + | s.mu.RUnlock() | |||
| 238 | + | ||||
| 232 | containerName := s.deploymentContainerName(in.Domain, in.Existing) | 239 | containerName := s.deploymentContainerName(in.Domain, in.Existing) | ||
| 233 | labels := map[string]string{ | 240 | labels := map[string]string{ | ||
| 234 | domain.LabelDomain: in.Domain, | 241 | domain.LabelDomain: in.Domain, | ||
| @@ -266,6 +273,9 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe | |||||
| 266 | Hostname: in.Domain, | 273 | Hostname: in.Domain, | ||
| 267 | Labels: labels, | 274 | Labels: labels, | ||
| 268 | AutoRemove: false, | 275 | AutoRemove: false, | ||
| 276 | + | MemoryLimit: cfg.DefaultMemoryLimit, | |||
| 277 | + | NanoCPUs: cfg.DefaultNanoCPUs, | |||
| 278 | + | PidsLimit: cfg.DefaultPidsLimit, | |||
| 269 | } | 279 | } | ||
| 270 | } | 280 | } | ||
| 271 | 281 | ||||
| @@ -2232,6 +2242,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2232 | } | 2242 | } | ||
| 2233 | envHash := hashEnvironment(envVars) | 2243 | envHash := hashEnvironment(envVars) | ||
| 2234 | 2244 | ||||
| 2245 | + | s.mu.RLock() | |||
| 2246 | + | cfg := s.config | |||
| 2247 | + | s.mu.RUnlock() | |||
| 2248 | + | ||||
| 2235 | // Create container with attachment labels (use actual ref, track original in labels) | 2249 | // Create container with attachment labels (use actual ref, track original in labels) | ||
| 2236 | config := &domain.ContainerConfig{ | 2250 | config := &domain.ContainerConfig{ | ||
| 2237 | Image: actualImageRef, | 2251 | Image: actualImageRef, | ||
| @@ -2248,6 +2262,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2248 | domain.LabelEnvHash: envHash, | 2262 | domain.LabelEnvHash: envHash, | ||
| 2249 | domain.LabelImage: serviceImage, | 2263 | domain.LabelImage: serviceImage, | ||
| 2250 | }, | 2264 | }, | ||
| 2265 | + | MemoryLimit: cfg.DefaultMemoryLimit, | |||
| 2266 | + | NanoCPUs: cfg.DefaultNanoCPUs, | |||
| 2267 | + | PidsLimit: cfg.DefaultPidsLimit, | |||
| 2251 | } | 2268 | } | ||
| 2252 | 2269 | ||||
| 2253 | container, err := s.runtime.CreateContainer(ctx, config) | 2270 | container, err := s.runtime.CreateContainer(ctx, config) | ||
M pkg/validation/path.go +17 -0
| @@ -127,6 +127,23 @@ func ValidateUUID(uuid string) error { | |||
| 127 | 127 | return nil | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | + | // ValidateDomainParam validates a domain name extracted from a URL path parameter. | |
| 131 | + | // Rejects path traversal, null bytes, and control characters. | |
| 132 | + | func ValidateDomainParam(domain string) error { | |
| 133 | + | if domain == "" { | |
| 134 | + | return fmt.Errorf("domain parameter is empty") | |
| 135 | + | } | |
| 136 | + | if strings.Contains(domain, "..") { | |
| 137 | + | return fmt.Errorf("domain contains path traversal sequence") | |
| 138 | + | } | |
| 139 | + | if strings.ContainsRune(domain, '/') || strings.IndexFunc(domain, func(r rune) bool { | |
| 140 | + | return r < 0x20 || r == 0x7f | |
| 141 | + | }) != -1 { | |
| 142 | + | return fmt.Errorf("domain contains invalid characters") | |
| 143 | + | } | |
| 144 | + | return nil | |
| 145 | + | } | |
| 146 | + | ||
| 130 | 147 | // ValidatePath sanitizes and validates a path component to prevent traversal attacks. | |
| 131 | 148 | // Returns the cleaned path or an error if the path is unsafe. | |
| 132 | 149 | func ValidatePath(path string) (string, error) { | |
| @@ -127,6 +127,23 @@ func ValidateUUID(uuid string) error { | |||||
| 127 | return nil | 127 | return nil | ||
| 128 | } | 128 | } | ||
| 129 | 129 | ||||
| 130 | + | // ValidateDomainParam validates a domain name extracted from a URL path parameter. | |||
| 131 | + | // Rejects path traversal, null bytes, and control characters. | |||
| 132 | + | func ValidateDomainParam(domain string) error { | |||
| 133 | + | if domain == "" { | |||
| 134 | + | return fmt.Errorf("domain parameter is empty") | |||
| 135 | + | } | |||
| 136 | + | if strings.Contains(domain, "..") { | |||
| 137 | + | return fmt.Errorf("domain contains path traversal sequence") | |||
| 138 | + | } | |||
| 139 | + | if strings.ContainsRune(domain, '/') || strings.IndexFunc(domain, func(r rune) bool { | |||
| 140 | + | return r < 0x20 || r == 0x7f | |||
| 141 | + | }) != -1 { | |||
| 142 | + | return fmt.Errorf("domain contains invalid characters") | |||
| 143 | + | } | |||
| 144 | + | return nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 130 | // ValidatePath sanitizes and validates a path component to prevent traversal attacks. | 147 | // ValidatePath sanitizes and validates a path component to prevent traversal attacks. | ||
| 131 | // Returns the cleaned path or an error if the path is unsafe. | 148 | // Returns the cleaned path or an error if the path is unsafe. | ||
| 132 | func ValidatePath(path string) (string, error) { | 149 | func ValidatePath(path string) (string, error) { | ||
M pkg/validation/path_test.go +31 -0
| @@ -3,6 +3,8 @@ package validation | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestValidateRepositoryName(t *testing.T) { | |
| @@ -201,6 +203,35 @@ func TestValidateUUID(t *testing.T) { | |||
| 201 | 203 | } | |
| 202 | 204 | } | |
| 203 | 205 | ||
| 206 | + | func TestValidateDomainParam(t *testing.T) { | |
| 207 | + | tests := []struct { | |
| 208 | + | name string | |
| 209 | + | input string | |
| 210 | + | valid bool | |
| 211 | + | }{ | |
| 212 | + | {"valid domain", "example.com", true}, | |
| 213 | + | {"valid subdomain", "app.example.com", true}, | |
| 214 | + | {"path traversal", "../etc/passwd", false}, | |
| 215 | + | {"null byte", "example\x00.com", false}, | |
| 216 | + | {"newline", "example\n.com", false}, | |
| 217 | + | {"carriage return", "example\r.com", false}, | |
| 218 | + | {"empty", "", false}, | |
| 219 | + | {"double dot in middle", "foo..bar", false}, | |
| 220 | + | {"forward slash", "example/com", false}, | |
| 221 | + | {"tab character", "example\t.com", false}, | |
| 222 | + | } | |
| 223 | + | for _, tt := range tests { | |
| 224 | + | t.Run(tt.name, func(t *testing.T) { | |
| 225 | + | err := ValidateDomainParam(tt.input) | |
| 226 | + | if tt.valid { | |
| 227 | + | require.NoError(t, err, "input: %q", tt.input) | |
| 228 | + | } else { | |
| 229 | + | require.Error(t, err, "input: %q", tt.input) | |
| 230 | + | } | |
| 231 | + | }) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 204 | 235 | func TestValidatePath(t *testing.T) { | |
| 205 | 236 | tests := []struct { | |
| 206 | 237 | name string | |
| @@ -3,6 +3,8 @@ package validation | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strings" | 4 | "strings" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | func TestValidateRepositoryName(t *testing.T) { | 10 | func TestValidateRepositoryName(t *testing.T) { | ||
| @@ -201,6 +203,35 @@ func TestValidateUUID(t *testing.T) { | |||||
| 201 | } | 203 | } | ||
| 202 | } | 204 | } | ||
| 203 | 205 | ||||
| 206 | + | func TestValidateDomainParam(t *testing.T) { | |||
| 207 | + | tests := []struct { | |||
| 208 | + | name string | |||
| 209 | + | input string | |||
| 210 | + | valid bool | |||
| 211 | + | }{ | |||
| 212 | + | {"valid domain", "example.com", true}, | |||
| 213 | + | {"valid subdomain", "app.example.com", true}, | |||
| 214 | + | {"path traversal", "../etc/passwd", false}, | |||
| 215 | + | {"null byte", "example\x00.com", false}, | |||
| 216 | + | {"newline", "example\n.com", false}, | |||
| 217 | + | {"carriage return", "example\r.com", false}, | |||
| 218 | + | {"empty", "", false}, | |||
| 219 | + | {"double dot in middle", "foo..bar", false}, | |||
| 220 | + | {"forward slash", "example/com", false}, | |||
| 221 | + | {"tab character", "example\t.com", false}, | |||
| 222 | + | } | |||
| 223 | + | for _, tt := range tests { | |||
| 224 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 225 | + | err := ValidateDomainParam(tt.input) | |||
| 226 | + | if tt.valid { | |||
| 227 | + | require.NoError(t, err, "input: %q", tt.input) | |||
| 228 | + | } else { | |||
| 229 | + | require.Error(t, err, "input: %q", tt.input) | |||
| 230 | + | } | |||
| 231 | + | }) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 204 | func TestValidatePath(t *testing.T) { | 235 | func TestValidatePath(t *testing.T) { | ||
| 205 | tests := []struct { | 236 | tests := []struct { | ||
| 206 | name string | 237 | name string | ||