Merge pull request 'feat(tokens): branch-limited tokens for agents' (#13) from feat/token-branch-prefixes into main
44 files · +1515 -114
M README.md +14 -1
| @@ -62,8 +62,10 @@ ermite config show [--json] # path and stored values | |||
| 62 | 62 | ```sh | |
| 63 | 63 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | |
| 64 | 64 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes] | |
| 65 | - | ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once | |
| 65 | + | ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once | |
| 66 | 66 | ermite token list | revoke <id> | |
| 67 | + | # a token for an AI agent that cannot touch main: | |
| 68 | + | ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/ | |
| 67 | 69 | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | |
| 68 | 70 | ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment> | |
| 69 | 71 | ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n> | |
| @@ -91,6 +93,17 @@ git push http://u:<token>@git.example.com/<repo>.git main | |||
| 91 | 93 | ||
| 92 | 94 | Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`). | |
| 93 | 95 | ||
| 96 | + | ## Branch-limited tokens | |
| 97 | + | ||
| 98 | + | For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes. | |
| 99 | + | ||
| 100 | + | - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref. | |
| 101 | + | - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own. | |
| 102 | + | - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request. | |
| 103 | + | - CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository. | |
| 104 | + | - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`. | |
| 105 | + | - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade. | |
| 106 | + | ||
| 94 | 107 | ## Merge requests | |
| 95 | 108 | ||
| 96 | 109 | A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch). | |
| @@ -62,8 +62,10 @@ ermite config show [--json] # path and stored values | |||||
| 62 | ```sh | 62 | ```sh | ||
| 63 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | 63 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | ||
| 64 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes] | 64 | ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes] | ||
| 65 | - | ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once | 65 | + | ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once |
| 66 | ermite token list | revoke <id> | 66 | ermite token list | revoke <id> | ||
| 67 | + | # a token for an AI agent that cannot touch main: | |||
| 68 | + | ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/ | |||
| 67 | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | 69 | ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] | ||
| 68 | ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment> | 70 | ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment> | ||
| 69 | ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n> | 71 | ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n> | ||
| @@ -91,6 +93,17 @@ git push http://u:<token>@git.example.com/<repo>.git main | |||||
| 91 | 93 | ||||
| 92 | Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`). | 94 | Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`). | ||
| 93 | 95 | ||||
| 96 | + | ## Branch-limited tokens | |||
| 97 | + | ||||
| 98 | + | For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes. | |||
| 99 | + | ||||
| 100 | + | - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref. | |||
| 101 | + | - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own. | |||
| 102 | + | - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request. | |||
| 103 | + | - CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository. | |||
| 104 | + | - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`. | |||
| 105 | + | - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade. | |||
| 106 | + | ||||
| 94 | ## Merge requests | 107 | ## Merge requests | ||
| 95 | 108 | ||||
| 96 | A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch). | 109 | A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch). | ||
M gen/ermite/storage/repo.pb.go +16 -5
| @@ -226,9 +226,12 @@ type Token struct { | |||
| 226 | 226 | ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` | |
| 227 | 227 | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 228 | 228 | // Unset until the token is first used. | |
| 229 | - | LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"` | |
| 230 | - | unknownFields protoimpl.UnknownFields | |
| 231 | - | sizeCache protoimpl.SizeCache | |
| 229 | + | LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"` | |
| 230 | + | // Branch prefixes, each ending with "/", that the token may write; empty | |
| 231 | + | // means every branch. Added in storage schema 2. | |
| 232 | + | BranchPrefixes []string `protobuf:"bytes,9,rep,name=branch_prefixes,json=branchPrefixes,proto3" json:"branch_prefixes,omitempty"` | |
| 233 | + | unknownFields protoimpl.UnknownFields | |
| 234 | + | sizeCache protoimpl.SizeCache | |
| 232 | 235 | } | |
| 233 | 236 | ||
| 234 | 237 | func (x *Token) Reset() { | |
| @@ -317,6 +320,13 @@ func (x *Token) GetLastUsedAt() *timestamppb.Timestamp { | |||
| 317 | 320 | return nil | |
| 318 | 321 | } | |
| 319 | 322 | ||
| 323 | + | func (x *Token) GetBranchPrefixes() []string { | |
| 324 | + | if x != nil { | |
| 325 | + | return x.BranchPrefixes | |
| 326 | + | } | |
| 327 | + | return nil | |
| 328 | + | } | |
| 329 | + | ||
| 320 | 330 | var File_ermite_storage_repo_proto protoreflect.FileDescriptor | |
| 321 | 331 | ||
| 322 | 332 | const file_ermite_storage_repo_proto_rawDesc = "" + | |
| @@ -333,7 +343,7 @@ const file_ermite_storage_repo_proto_rawDesc = "" + | |||
| 333 | 343 | "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\x128\n" + | |
| 334 | 344 | "\bsettings\x18\x06 \x01(\v2\x1c.ermite.storage.RepoSettingsR\bsettings\")\n" + | |
| 335 | 345 | "\rSchemaVersion\x12\x18\n" + | |
| 336 | - | "\aversion\x18\x01 \x01(\rR\aversion\"\xa3\x02\n" + | |
| 346 | + | "\aversion\x18\x01 \x01(\rR\aversion\"\xcc\x02\n" + | |
| 337 | 347 | "\x05Token\x12\x0e\n" + | |
| 338 | 348 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + | |
| 339 | 349 | "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" + | |
| @@ -345,7 +355,8 @@ const file_ermite_storage_repo_proto_rawDesc = "" + | |||
| 345 | 355 | "\n" + | |
| 346 | 356 | "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" + | |
| 347 | 357 | "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + | |
| 348 | - | "lastUsedAt*W\n" + | |
| 358 | + | "lastUsedAt\x12'\n" + | |
| 359 | + | "\x0fbranch_prefixes\x18\t \x03(\tR\x0ebranchPrefixes*W\n" + | |
| 349 | 360 | "\n" + | |
| 350 | 361 | "Visibility\x12\x1a\n" + | |
| 351 | 362 | "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" + | |
| @@ -226,9 +226,12 @@ type Token struct { | |||||
| 226 | ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` | 226 | ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` | ||
| 227 | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | 227 | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | ||
| 228 | // Unset until the token is first used. | 228 | // Unset until the token is first used. | ||
| 229 | - | LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"` | 229 | + | LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"` |
| 230 | - | unknownFields protoimpl.UnknownFields | 230 | + | // Branch prefixes, each ending with "/", that the token may write; empty |
| 231 | - | sizeCache protoimpl.SizeCache | 231 | + | // means every branch. Added in storage schema 2. |
| 232 | + | BranchPrefixes []string `protobuf:"bytes,9,rep,name=branch_prefixes,json=branchPrefixes,proto3" json:"branch_prefixes,omitempty"` | |||
| 233 | + | unknownFields protoimpl.UnknownFields | |||
| 234 | + | sizeCache protoimpl.SizeCache | |||
| 232 | } | 235 | } | ||
| 233 | 236 | ||||
| 234 | func (x *Token) Reset() { | 237 | func (x *Token) Reset() { | ||
| @@ -317,6 +320,13 @@ func (x *Token) GetLastUsedAt() *timestamppb.Timestamp { | |||||
| 317 | return nil | 320 | return nil | ||
| 318 | } | 321 | } | ||
| 319 | 322 | ||||
| 323 | + | func (x *Token) GetBranchPrefixes() []string { | |||
| 324 | + | if x != nil { | |||
| 325 | + | return x.BranchPrefixes | |||
| 326 | + | } | |||
| 327 | + | return nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 320 | var File_ermite_storage_repo_proto protoreflect.FileDescriptor | 330 | var File_ermite_storage_repo_proto protoreflect.FileDescriptor | ||
| 321 | 331 | ||||
| 322 | const file_ermite_storage_repo_proto_rawDesc = "" + | 332 | const file_ermite_storage_repo_proto_rawDesc = "" + | ||
| @@ -333,7 +343,7 @@ const file_ermite_storage_repo_proto_rawDesc = "" + | |||||
| 333 | "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\x128\n" + | 343 | "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\x128\n" + | ||
| 334 | "\bsettings\x18\x06 \x01(\v2\x1c.ermite.storage.RepoSettingsR\bsettings\")\n" + | 344 | "\bsettings\x18\x06 \x01(\v2\x1c.ermite.storage.RepoSettingsR\bsettings\")\n" + | ||
| 335 | "\rSchemaVersion\x12\x18\n" + | 345 | "\rSchemaVersion\x12\x18\n" + | ||
| 336 | - | "\aversion\x18\x01 \x01(\rR\aversion\"\xa3\x02\n" + | 346 | + | "\aversion\x18\x01 \x01(\rR\aversion\"\xcc\x02\n" + |
| 337 | "\x05Token\x12\x0e\n" + | 347 | "\x05Token\x12\x0e\n" + | ||
| 338 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + | 348 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + | ||
| 339 | "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" + | 349 | "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" + | ||
| @@ -345,7 +355,8 @@ const file_ermite_storage_repo_proto_rawDesc = "" + | |||||
| 345 | "\n" + | 355 | "\n" + | ||
| 346 | "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" + | 356 | "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" + | ||
| 347 | "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + | 357 | "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + | ||
| 348 | - | "lastUsedAt*W\n" + | 358 | + | "lastUsedAt\x12'\n" + |
| 359 | + | "\x0fbranch_prefixes\x18\t \x03(\tR\x0ebranchPrefixes*W\n" + | |||
| 349 | "\n" + | 360 | "\n" + | ||
| 350 | "Visibility\x12\x1a\n" + | 361 | "Visibility\x12\x1a\n" + | ||
| 351 | "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" + | 362 | "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" + | ||
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | |
| 16 | - | bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b7 | |
| 16 | + | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| @@ -13,7 +13,7 @@ tool ( | |||||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | 15 | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | ||
| 16 | - | bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b7 | 16 | + | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | connectrpc.com/connect v1.21.0 | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client_test.go +2 -2
| @@ -139,11 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | - | client.FeatureCIRuns, client.FeatureCIRunners, | |
| 146 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, | |
| 147 | 147 | })) | |
| 148 | 148 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 149 | 149 | } | |
| @@ -139,11 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||||
| 139 | func TestMissingFeatures(t *testing.T) { | 139 | func TestMissingFeatures(t *testing.T) { | ||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | assert.Equal(t, | 141 | assert.Equal(t, | ||
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners}, | 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners}, |
| 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | ||
| 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | ||
| 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | ||
| 146 | - | client.FeatureCIRuns, client.FeatureCIRunners, | 146 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, |
| 147 | })) | 147 | })) | ||
| 148 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 148 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
| 149 | } | 149 | } | ||
M internal/adapters/client/features.go +8 -1
| @@ -22,6 +22,13 @@ const ( | |||
| 22 | 22 | FeatureSigningKey = "signing_key" | |
| 23 | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| 24 | 24 | FeatureSession = "session" | |
| 25 | + | // FeatureTokenBranchPrefixes is Token.branch_prefixes and | |
| 26 | + | // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`). | |
| 27 | + | // A server without it ignores the field and would create an unlimited | |
| 28 | + | // token: `token create --branch-prefix` reads the features first and | |
| 29 | + | // refuses when it is missing (the main guard), and also checks the token it | |
| 30 | + | // gets back (a backstop). | |
| 31 | + | FeatureTokenBranchPrefixes = "token.branch_prefixes" | |
| 25 | 32 | // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner | |
| 26 | 33 | // calls (`ermite runner`). | |
| 27 | 34 | FeatureCIRuns = "ci.runs" | |
| @@ -30,7 +37,7 @@ const ( | |||
| 30 | 37 | ||
| 31 | 38 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 32 | 39 | func UsedFeatures() []string { | |
| 33 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners} | |
| 40 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners} | |
| 34 | 41 | } | |
| 35 | 42 | ||
| 36 | 43 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
| @@ -22,6 +22,13 @@ const ( | |||||
| 22 | FeatureSigningKey = "signing_key" | 22 | FeatureSigningKey = "signing_key" | ||
| 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | 23 | // FeatureSession is SessionService (`ermite web login`, `web logout`). | ||
| 24 | FeatureSession = "session" | 24 | FeatureSession = "session" | ||
| 25 | + | // FeatureTokenBranchPrefixes is Token.branch_prefixes and | |||
| 26 | + | // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`). | |||
| 27 | + | // A server without it ignores the field and would create an unlimited | |||
| 28 | + | // token: `token create --branch-prefix` reads the features first and | |||
| 29 | + | // refuses when it is missing (the main guard), and also checks the token it | |||
| 30 | + | // gets back (a backstop). | |||
| 31 | + | FeatureTokenBranchPrefixes = "token.branch_prefixes" | |||
| 25 | // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner | 32 | // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner | ||
| 26 | // calls (`ermite runner`). | 33 | // calls (`ermite runner`). | ||
| 27 | FeatureCIRuns = "ci.runs" | 34 | FeatureCIRuns = "ci.runs" | ||
| @@ -30,7 +37,7 @@ const ( | |||||
| 30 | 37 | ||||
| 31 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 38 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 32 | func UsedFeatures() []string { | 39 | func UsedFeatures() []string { | ||
| 33 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners} | 40 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners} |
| 34 | } | 41 | } | ||
| 35 | 42 | ||||
| 36 | // MissingFeatures returns the features the CLI uses that have is lacking. | 43 | // MissingFeatures returns the features the CLI uses that have is lacking. | ||
M internal/adapters/kvstore/store.go +23 -4
| @@ -17,8 +17,11 @@ import ( | |||
| 17 | 17 | "bnema.dev/ermite/internal/ports" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | - | // SchemaVersion is the storage schema this build reads and writes. | |
| 21 | - | const SchemaVersion = 1 | |
| 20 | + | // SchemaVersion is the storage schema this build reads and writes. Version 2 | |
| 21 | + | // adds branch prefixes to tokens: a build that does not know them would treat | |
| 22 | + | // a branch-limited token as unrestricted, so it must refuse the data | |
| 23 | + | // (ErrSchemaTooNew) instead. | |
| 24 | + | const SchemaVersion = 2 | |
| 22 | 25 | ||
| 23 | 26 | const schemaKey = "meta/schema" | |
| 24 | 27 | ||
| @@ -33,8 +36,8 @@ type Store struct { | |||
| 33 | 36 | var _ ports.RepoMetaStore = (*Store)(nil) | |
| 34 | 37 | ||
| 35 | 38 | // Open opens (or creates) the database in dir and checks the storage schema: | |
| 36 | - | // a missing version is written as SchemaVersion, a higher one stops with | |
| 37 | - | // ErrSchemaTooNew. dir must exist. | |
| 39 | + | // a missing version is written as SchemaVersion, a lower one is upgraded to it, | |
| 40 | + | // a higher one stops with ErrSchemaTooNew. dir must exist. | |
| 38 | 41 | func Open(ctx context.Context, dir string) (*Store, error) { | |
| 39 | 42 | db, err := kv.Open(ctx, dir, kv.Options{}) | |
| 40 | 43 | if err != nil { | |
| @@ -107,6 +110,22 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 107 | 110 | case sv.GetVersion() == 0: | |
| 108 | 111 | return fmt.Errorf("decode storage schema: invalid version 0") | |
| 109 | 112 | } | |
| 113 | + | if sv.GetVersion() < SchemaVersion { | |
| 114 | + | // Older data stays readable as it is (the changes are additive); | |
| 115 | + | // the new version is recorded so that older builds refuse it. | |
| 116 | + | var b kv.Batch | |
| 117 | + | b.RequireVersion(key, item.Version) | |
| 118 | + | b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion})) | |
| 119 | + | _, err := s.db.Apply(ctx, &b) | |
| 120 | + | if errors.Is(err, kv.ErrConflict) { | |
| 121 | + | continue // changed meanwhile: read it again | |
| 122 | + | } | |
| 123 | + | if err != nil { | |
| 124 | + | return fmt.Errorf("upgrade storage schema: %w", err) | |
| 125 | + | } | |
| 126 | + | log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded") | |
| 127 | + | return nil | |
| 128 | + | } | |
| 110 | 129 | log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked") | |
| 111 | 130 | return nil | |
| 112 | 131 | } | |
| @@ -17,8 +17,11 @@ import ( | |||||
| 17 | "bnema.dev/ermite/internal/ports" | 17 | "bnema.dev/ermite/internal/ports" | ||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | - | // SchemaVersion is the storage schema this build reads and writes. | 20 | + | // SchemaVersion is the storage schema this build reads and writes. Version 2 |
| 21 | - | const SchemaVersion = 1 | 21 | + | // adds branch prefixes to tokens: a build that does not know them would treat |
| 22 | + | // a branch-limited token as unrestricted, so it must refuse the data | |||
| 23 | + | // (ErrSchemaTooNew) instead. | |||
| 24 | + | const SchemaVersion = 2 | |||
| 22 | 25 | ||||
| 23 | const schemaKey = "meta/schema" | 26 | const schemaKey = "meta/schema" | ||
| 24 | 27 | ||||
| @@ -33,8 +36,8 @@ type Store struct { | |||||
| 33 | var _ ports.RepoMetaStore = (*Store)(nil) | 36 | var _ ports.RepoMetaStore = (*Store)(nil) | ||
| 34 | 37 | ||||
| 35 | // Open opens (or creates) the database in dir and checks the storage schema: | 38 | // Open opens (or creates) the database in dir and checks the storage schema: | ||
| 36 | - | // a missing version is written as SchemaVersion, a higher one stops with | 39 | + | // a missing version is written as SchemaVersion, a lower one is upgraded to it, |
| 37 | - | // ErrSchemaTooNew. dir must exist. | 40 | + | // a higher one stops with ErrSchemaTooNew. dir must exist. |
| 38 | func Open(ctx context.Context, dir string) (*Store, error) { | 41 | func Open(ctx context.Context, dir string) (*Store, error) { | ||
| 39 | db, err := kv.Open(ctx, dir, kv.Options{}) | 42 | db, err := kv.Open(ctx, dir, kv.Options{}) | ||
| 40 | if err != nil { | 43 | if err != nil { | ||
| @@ -107,6 +110,22 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||||
| 107 | case sv.GetVersion() == 0: | 110 | case sv.GetVersion() == 0: | ||
| 108 | return fmt.Errorf("decode storage schema: invalid version 0") | 111 | return fmt.Errorf("decode storage schema: invalid version 0") | ||
| 109 | } | 112 | } | ||
| 113 | + | if sv.GetVersion() < SchemaVersion { | |||
| 114 | + | // Older data stays readable as it is (the changes are additive); | |||
| 115 | + | // the new version is recorded so that older builds refuse it. | |||
| 116 | + | var b kv.Batch | |||
| 117 | + | b.RequireVersion(key, item.Version) | |||
| 118 | + | b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion})) | |||
| 119 | + | _, err := s.db.Apply(ctx, &b) | |||
| 120 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 121 | + | continue // changed meanwhile: read it again | |||
| 122 | + | } | |||
| 123 | + | if err != nil { | |||
| 124 | + | return fmt.Errorf("upgrade storage schema: %w", err) | |||
| 125 | + | } | |||
| 126 | + | log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded") | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 110 | log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked") | 129 | log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked") | ||
| 111 | return nil | 130 | return nil | ||
| 112 | } | 131 | } | ||
M internal/adapters/kvstore/store_test.go +18 -5
| @@ -191,12 +191,12 @@ func readSchema(t *testing.T, dir string) uint32 { | |||
| 191 | 191 | func TestSchema(t *testing.T) { | |
| 192 | 192 | ctx := context.Background() | |
| 193 | 193 | ||
| 194 | - | t.Run("missing is written as 1", func(t *testing.T) { | |
| 194 | + | t.Run("missing is written as the current version", func(t *testing.T) { | |
| 195 | 195 | dir := t.TempDir() | |
| 196 | 196 | s, err := kvstore.Open(ctx, dir) | |
| 197 | 197 | require.NoError(t, err) | |
| 198 | 198 | require.NoError(t, s.Close(ctx)) | |
| 199 | - | assert.Equal(t, uint32(1), readSchema(t, dir)) | |
| 199 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 200 | 200 | }) | |
| 201 | 201 | ||
| 202 | 202 | t.Run("current is accepted and kept", func(t *testing.T) { | |
| @@ -207,15 +207,28 @@ func TestSchema(t *testing.T) { | |||
| 207 | 207 | s, err = kvstore.Open(ctx, dir) | |
| 208 | 208 | require.NoError(t, err) | |
| 209 | 209 | require.NoError(t, s.Close(ctx)) | |
| 210 | - | assert.Equal(t, uint32(1), readSchema(t, dir)) | |
| 210 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 211 | + | }) | |
| 212 | + | ||
| 213 | + | t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) { | |
| 214 | + | dir := t.TempDir() | |
| 215 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1})) | |
| 216 | + | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | |
| 217 | + | s, err := kvstore.Open(ctx, dir) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | got, err := s.Get(ctx, name(t, "old")) | |
| 220 | + | require.NoError(t, err) | |
| 221 | + | assert.Equal(t, "old", got.Name.String()) | |
| 222 | + | require.NoError(t, s.Close(ctx)) | |
| 223 | + | assert.Equal(t, uint32(2), readSchema(t, dir)) | |
| 211 | 224 | }) | |
| 212 | 225 | ||
| 213 | 226 | t.Run("newer stops startup and releases the lock", func(t *testing.T) { | |
| 214 | 227 | dir := t.TempDir() | |
| 215 | - | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |
| 228 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3})) | |
| 216 | 229 | _, err := kvstore.Open(ctx, dir) | |
| 217 | 230 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | |
| 218 | - | assert.Equal(t, uint32(2), readSchema(t, dir), "the lock was released and the value kept") | |
| 231 | + | assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept") | |
| 219 | 232 | }) | |
| 220 | 233 | ||
| 221 | 234 | t.Run("garbage and zero are errors", func(t *testing.T) { | |
| @@ -191,12 +191,12 @@ func readSchema(t *testing.T, dir string) uint32 { | |||||
| 191 | func TestSchema(t *testing.T) { | 191 | func TestSchema(t *testing.T) { | ||
| 192 | ctx := context.Background() | 192 | ctx := context.Background() | ||
| 193 | 193 | ||||
| 194 | - | t.Run("missing is written as 1", func(t *testing.T) { | 194 | + | t.Run("missing is written as the current version", func(t *testing.T) { |
| 195 | dir := t.TempDir() | 195 | dir := t.TempDir() | ||
| 196 | s, err := kvstore.Open(ctx, dir) | 196 | s, err := kvstore.Open(ctx, dir) | ||
| 197 | require.NoError(t, err) | 197 | require.NoError(t, err) | ||
| 198 | require.NoError(t, s.Close(ctx)) | 198 | require.NoError(t, s.Close(ctx)) | ||
| 199 | - | assert.Equal(t, uint32(1), readSchema(t, dir)) | 199 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) |
| 200 | }) | 200 | }) | ||
| 201 | 201 | ||||
| 202 | t.Run("current is accepted and kept", func(t *testing.T) { | 202 | t.Run("current is accepted and kept", func(t *testing.T) { | ||
| @@ -207,15 +207,28 @@ func TestSchema(t *testing.T) { | |||||
| 207 | s, err = kvstore.Open(ctx, dir) | 207 | s, err = kvstore.Open(ctx, dir) | ||
| 208 | require.NoError(t, err) | 208 | require.NoError(t, err) | ||
| 209 | require.NoError(t, s.Close(ctx)) | 209 | require.NoError(t, s.Close(ctx)) | ||
| 210 | - | assert.Equal(t, uint32(1), readSchema(t, dir)) | 210 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) |
| 211 | + | }) | |||
| 212 | + | ||||
| 213 | + | t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) { | |||
| 214 | + | dir := t.TempDir() | |||
| 215 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1})) | |||
| 216 | + | putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})) | |||
| 217 | + | s, err := kvstore.Open(ctx, dir) | |||
| 218 | + | require.NoError(t, err) | |||
| 219 | + | got, err := s.Get(ctx, name(t, "old")) | |||
| 220 | + | require.NoError(t, err) | |||
| 221 | + | assert.Equal(t, "old", got.Name.String()) | |||
| 222 | + | require.NoError(t, s.Close(ctx)) | |||
| 223 | + | assert.Equal(t, uint32(2), readSchema(t, dir)) | |||
| 211 | }) | 224 | }) | ||
| 212 | 225 | ||||
| 213 | t.Run("newer stops startup and releases the lock", func(t *testing.T) { | 226 | t.Run("newer stops startup and releases the lock", func(t *testing.T) { | ||
| 214 | dir := t.TempDir() | 227 | dir := t.TempDir() | ||
| 215 | - | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | 228 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3})) |
| 216 | _, err := kvstore.Open(ctx, dir) | 229 | _, err := kvstore.Open(ctx, dir) | ||
| 217 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | 230 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | ||
| 218 | - | assert.Equal(t, uint32(2), readSchema(t, dir), "the lock was released and the value kept") | 231 | + | assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept") |
| 219 | }) | 232 | }) | ||
| 220 | 233 | ||||
| 221 | t.Run("garbage and zero are errors", func(t *testing.T) { | 234 | t.Run("garbage and zero are errors", func(t *testing.T) { | ||
M internal/adapters/kvstore/tokens.go +13 -0
| @@ -191,6 +191,11 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | |||
| 191 | 191 | return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict) | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | + | // encodeToken refuses what the use case already refused (scopes, branch | |
| 195 | + | // prefixes): a malformed prefix must never reach the store, where it would | |
| 196 | + | // limit a token wrongly or not at all. | |
| 197 | + | // | |
| 198 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy | |
| 194 | 199 | func encodeToken(t domain.Token) ([]byte, error) { | |
| 195 | 200 | if !validTokenID(t.ID) { | |
| 196 | 201 | return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID) | |
| @@ -221,6 +226,12 @@ func encodeToken(t domain.Token) ([]byte, error) { | |||
| 221 | 226 | } | |
| 222 | 227 | pb.Repos = append(pb.Repos, r.String()) | |
| 223 | 228 | } | |
| 229 | + | for _, bp := range t.BranchPrefixes { | |
| 230 | + | if _, err := domain.ParseBranchPrefix(bp); err != nil { | |
| 231 | + | return nil, err | |
| 232 | + | } | |
| 233 | + | pb.BranchPrefixes = append(pb.BranchPrefixes, bp) | |
| 234 | + | } | |
| 224 | 235 | return mustMarshal(pb), nil | |
| 225 | 236 | } | |
| 226 | 237 | ||
| @@ -256,5 +267,7 @@ func decodeToken(val []byte) (domain.Token, error) { | |||
| 256 | 267 | } | |
| 257 | 268 | t.Repos = append(t.Repos, name) | |
| 258 | 269 | } | |
| 270 | + | // Kept as stored: a token with prefixes stays limited, whatever they say. | |
| 271 | + | t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes()) | |
| 259 | 272 | return t, nil | |
| 260 | 273 | } | |
| @@ -191,6 +191,11 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | |||||
| 191 | return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict) | 191 | return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict) | ||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
| 194 | + | // encodeToken refuses what the use case already refused (scopes, branch | |||
| 195 | + | // prefixes): a malformed prefix must never reach the store, where it would | |||
| 196 | + | // limit a token wrongly or not at all. | |||
| 197 | + | // | |||
| 198 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy | |||
| 194 | func encodeToken(t domain.Token) ([]byte, error) { | 199 | func encodeToken(t domain.Token) ([]byte, error) { | ||
| 195 | if !validTokenID(t.ID) { | 200 | if !validTokenID(t.ID) { | ||
| 196 | return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID) | 201 | return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID) | ||
| @@ -221,6 +226,12 @@ func encodeToken(t domain.Token) ([]byte, error) { | |||||
| 221 | } | 226 | } | ||
| 222 | pb.Repos = append(pb.Repos, r.String()) | 227 | pb.Repos = append(pb.Repos, r.String()) | ||
| 223 | } | 228 | } | ||
| 229 | + | for _, bp := range t.BranchPrefixes { | |||
| 230 | + | if _, err := domain.ParseBranchPrefix(bp); err != nil { | |||
| 231 | + | return nil, err | |||
| 232 | + | } | |||
| 233 | + | pb.BranchPrefixes = append(pb.BranchPrefixes, bp) | |||
| 234 | + | } | |||
| 224 | return mustMarshal(pb), nil | 235 | return mustMarshal(pb), nil | ||
| 225 | } | 236 | } | ||
| 226 | 237 | ||||
| @@ -256,5 +267,7 @@ func decodeToken(val []byte) (domain.Token, error) { | |||||
| 256 | } | 267 | } | ||
| 257 | t.Repos = append(t.Repos, name) | 268 | t.Repos = append(t.Repos, name) | ||
| 258 | } | 269 | } | ||
| 270 | + | // Kept as stored: a token with prefixes stays limited, whatever they say. | |||
| 271 | + | t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes()) | |||
| 259 | return t, nil | 272 | return t, nil | ||
| 260 | } | 273 | } | ||
M internal/adapters/kvstore/tokens_test.go +53 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 15 | 16 | "bnema.dev/ermite/internal/domain" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -192,3 +193,55 @@ func TestTokensConcurrentTouchAndRevoke(t *testing.T) { | |||
| 192 | 193 | assert.Len(t, list, 1) | |
| 193 | 194 | } | |
| 194 | 195 | } | |
| 196 | + | ||
| 197 | + | func TestTokensBranchPrefixes(t *testing.T) { | |
| 198 | + | ctx := context.Background() | |
| 199 | + | ||
| 200 | + | t.Run("round trip, order kept", func(t *testing.T) { | |
| 201 | + | s := openStore(t, t.TempDir()) | |
| 202 | + | tok := newToken(t, "t1", "s1") | |
| 203 | + | tok.BranchPrefixes = []string{"b/", "agent/claude/"} | |
| 204 | + | require.NoError(t, s.Tokens().Create(ctx, tok)) | |
| 205 | + | got, err := s.Tokens().Get(ctx, "t1") | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | assert.Equal(t, tok, got) | |
| 208 | + | byHash, err := s.Tokens().FindByHash(ctx, tok.Hash) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | assert.Equal(t, []string{"b/", "agent/claude/"}, byHash.BranchPrefixes) | |
| 211 | + | }) | |
| 212 | + | ||
| 213 | + | t.Run("a token stored before the field existed decodes with no prefixes", func(t *testing.T) { | |
| 214 | + | dir := t.TempDir() | |
| 215 | + | old := newToken(t, "old", "s-old") | |
| 216 | + | putRaw(t, dir, "token/old", mustProto(t, &storagepb.Token{ | |
| 217 | + | Id: "old", Label: "old", Hash: old.Hash[:], Scopes: []string{"repo:write"}, | |
| 218 | + | })) | |
| 219 | + | s := openStore(t, dir) | |
| 220 | + | got, err := s.Tokens().Get(ctx, "old") | |
| 221 | + | require.NoError(t, err) | |
| 222 | + | assert.Empty(t, got.BranchPrefixes) | |
| 223 | + | assert.Equal(t, []domain.Scope{domain.ScopeRepoWrite}, got.Scopes) | |
| 224 | + | }) | |
| 225 | + | ||
| 226 | + | t.Run("an invalid prefix is not stored", func(t *testing.T) { | |
| 227 | + | s := openStore(t, t.TempDir()) | |
| 228 | + | for _, bad := range []string{"", "agent", "/", "a b/"} { | |
| 229 | + | tok := newToken(t, "t1", "s1") | |
| 230 | + | tok.BranchPrefixes = []string{bad} | |
| 231 | + | assert.ErrorIs(t, s.Tokens().Create(ctx, tok), domain.ErrInvalidToken, "%q", bad) | |
| 232 | + | } | |
| 233 | + | _, err := s.Tokens().Get(ctx, "t1") | |
| 234 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 235 | + | }) | |
| 236 | + | ||
| 237 | + | t.Run("a stored prefix survives touch and stays limiting", func(t *testing.T) { | |
| 238 | + | s := openStore(t, t.TempDir()) | |
| 239 | + | tok := newToken(t, "t1", "s1") | |
| 240 | + | tok.BranchPrefixes = []string{"agent/"} | |
| 241 | + | require.NoError(t, s.Tokens().Create(ctx, tok)) | |
| 242 | + | require.NoError(t, s.Tokens().Touch(ctx, "t1", created.Add(time.Hour))) | |
| 243 | + | got, err := s.Tokens().Get(ctx, "t1") | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | assert.Equal(t, []string{"agent/"}, got.BranchPrefixes) | |
| 246 | + | }) | |
| 247 | + | } | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 15 | "bnema.dev/ermite/internal/domain" | 16 | "bnema.dev/ermite/internal/domain" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -192,3 +193,55 @@ func TestTokensConcurrentTouchAndRevoke(t *testing.T) { | |||||
| 192 | assert.Len(t, list, 1) | 193 | assert.Len(t, list, 1) | ||
| 193 | } | 194 | } | ||
| 194 | } | 195 | } | ||
| 196 | + | ||||
| 197 | + | func TestTokensBranchPrefixes(t *testing.T) { | |||
| 198 | + | ctx := context.Background() | |||
| 199 | + | ||||
| 200 | + | t.Run("round trip, order kept", func(t *testing.T) { | |||
| 201 | + | s := openStore(t, t.TempDir()) | |||
| 202 | + | tok := newToken(t, "t1", "s1") | |||
| 203 | + | tok.BranchPrefixes = []string{"b/", "agent/claude/"} | |||
| 204 | + | require.NoError(t, s.Tokens().Create(ctx, tok)) | |||
| 205 | + | got, err := s.Tokens().Get(ctx, "t1") | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | assert.Equal(t, tok, got) | |||
| 208 | + | byHash, err := s.Tokens().FindByHash(ctx, tok.Hash) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | assert.Equal(t, []string{"b/", "agent/claude/"}, byHash.BranchPrefixes) | |||
| 211 | + | }) | |||
| 212 | + | ||||
| 213 | + | t.Run("a token stored before the field existed decodes with no prefixes", func(t *testing.T) { | |||
| 214 | + | dir := t.TempDir() | |||
| 215 | + | old := newToken(t, "old", "s-old") | |||
| 216 | + | putRaw(t, dir, "token/old", mustProto(t, &storagepb.Token{ | |||
| 217 | + | Id: "old", Label: "old", Hash: old.Hash[:], Scopes: []string{"repo:write"}, | |||
| 218 | + | })) | |||
| 219 | + | s := openStore(t, dir) | |||
| 220 | + | got, err := s.Tokens().Get(ctx, "old") | |||
| 221 | + | require.NoError(t, err) | |||
| 222 | + | assert.Empty(t, got.BranchPrefixes) | |||
| 223 | + | assert.Equal(t, []domain.Scope{domain.ScopeRepoWrite}, got.Scopes) | |||
| 224 | + | }) | |||
| 225 | + | ||||
| 226 | + | t.Run("an invalid prefix is not stored", func(t *testing.T) { | |||
| 227 | + | s := openStore(t, t.TempDir()) | |||
| 228 | + | for _, bad := range []string{"", "agent", "/", "a b/"} { | |||
| 229 | + | tok := newToken(t, "t1", "s1") | |||
| 230 | + | tok.BranchPrefixes = []string{bad} | |||
| 231 | + | assert.ErrorIs(t, s.Tokens().Create(ctx, tok), domain.ErrInvalidToken, "%q", bad) | |||
| 232 | + | } | |||
| 233 | + | _, err := s.Tokens().Get(ctx, "t1") | |||
| 234 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 235 | + | }) | |||
| 236 | + | ||||
| 237 | + | t.Run("a stored prefix survives touch and stays limiting", func(t *testing.T) { | |||
| 238 | + | s := openStore(t, t.TempDir()) | |||
| 239 | + | tok := newToken(t, "t1", "s1") | |||
| 240 | + | tok.BranchPrefixes = []string{"agent/"} | |||
| 241 | + | require.NoError(t, s.Tokens().Create(ctx, tok)) | |||
| 242 | + | require.NoError(t, s.Tokens().Touch(ctx, "t1", created.Add(time.Hour))) | |||
| 243 | + | got, err := s.Tokens().Get(ctx, "t1") | |||
| 244 | + | require.NoError(t, err) | |||
| 245 | + | assert.Equal(t, []string{"agent/"}, got.BranchPrefixes) | |||
| 246 | + | }) | |||
| 247 | + | } | |||
M internal/adapters/rpc/api.go +1 -1
| @@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20 | |||
| 26 | 26 | ||
| 27 | 27 | // Features are the optional capabilities of the contract that this server | |
| 28 | 28 | // implements, as GetVersion lists them. | |
| 29 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"} | |
| 29 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"} | |
| 30 | 30 | ||
| 31 | 31 | // Config configures the API. | |
| 32 | 32 | type Config struct { | |
| @@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20 | |||||
| 26 | 26 | ||||
| 27 | // Features are the optional capabilities of the contract that this server | 27 | // Features are the optional capabilities of the contract that this server | ||
| 28 | // implements, as GetVersion lists them. | 28 | // implements, as GetVersion lists them. | ||
| 29 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"} | 29 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"} |
| 30 | 30 | ||||
| 31 | // Config configures the API. | 31 | // Config configures the API. | ||
| 32 | type Config struct { | 32 | type Config struct { | ||
M internal/adapters/rpc/convert.go +2 -0
| @@ -4,6 +4,7 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
| 7 | + | "slices" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "google.golang.org/protobuf/types/known/timestamppb" | |
| @@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token { | |||
| 155 | 156 | for _, r := range t.Repos { | |
| 156 | 157 | out.Repos = append(out.Repos, r.String()) | |
| 157 | 158 | } | |
| 159 | + | out.BranchPrefixes = slices.Clone(t.BranchPrefixes) | |
| 158 | 160 | return out | |
| 159 | 161 | } | |
| 160 | 162 | ||
| @@ -4,6 +4,7 @@ package rpc | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "slices" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "google.golang.org/protobuf/types/known/timestamppb" | 10 | "google.golang.org/protobuf/types/known/timestamppb" | ||
| @@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token { | |||||
| 155 | for _, r := range t.Repos { | 156 | for _, r := range t.Repos { | ||
| 156 | out.Repos = append(out.Repos, r.String()) | 157 | out.Repos = append(out.Repos, r.String()) | ||
| 157 | } | 158 | } | ||
| 159 | + | out.BranchPrefixes = slices.Clone(t.BranchPrefixes) | |||
| 158 | return out | 160 | return out | ||
| 159 | } | 161 | } | ||
| 160 | 162 | ||||
M internal/adapters/rpc/session.go +14 -0
| @@ -4,6 +4,7 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | ||
| 8 | 9 | "connectrpc.com/connect" | |
| 9 | 10 | ||
| @@ -11,6 +12,11 @@ import ( | |||
| 11 | 12 | "bnema.dev/ermite/internal/ports" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 15 | + | // errPasskeysUnavailable is what the passkey RPCs answer: this server has no | |
| 16 | + | // passkeys, which the contract reports as failed_precondition. The passkey | |
| 17 | + | // feature replaces these two methods. | |
| 18 | + | var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |
| 19 | + | ||
| 14 | 20 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | |
| 15 | 21 | // never calls it: sessions come from the link, and the API ignores cookies. | |
| 16 | 22 | type sessionService struct{ sessions ports.SessionService } | |
| @@ -29,3 +35,11 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque | |||
| 29 | 35 | } | |
| 30 | 36 | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | |
| 31 | 37 | } | |
| 38 | + | ||
| 39 | + | func (s *sessionService) ListPasskeys(context.Context, *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) { | |
| 40 | + | return nil, errPasskeysUnavailable | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (s *sessionService) RemovePasskey(context.Context, *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) { | |
| 44 | + | return nil, errPasskeysUnavailable | |
| 45 | + | } | |
| @@ -4,6 +4,7 @@ package rpc | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 7 | 8 | ||||
| 8 | "connectrpc.com/connect" | 9 | "connectrpc.com/connect" | ||
| 9 | 10 | ||||
| @@ -11,6 +12,11 @@ import ( | |||||
| 11 | "bnema.dev/ermite/internal/ports" | 12 | "bnema.dev/ermite/internal/ports" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 15 | + | // errPasskeysUnavailable is what the passkey RPCs answer: this server has no | |||
| 16 | + | // passkeys, which the contract reports as failed_precondition. The passkey | |||
| 17 | + | // feature replaces these two methods. | |||
| 18 | + | var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server")) | |||
| 19 | + | ||||
| 14 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | 20 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | ||
| 15 | // never calls it: sessions come from the link, and the API ignores cookies. | 21 | // never calls it: sessions come from the link, and the API ignores cookies. | ||
| 16 | type sessionService struct{ sessions ports.SessionService } | 22 | type sessionService struct{ sessions ports.SessionService } | ||
| @@ -29,3 +35,11 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque | |||||
| 29 | } | 35 | } | ||
| 30 | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | 36 | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | ||
| 31 | } | 37 | } | ||
| 38 | + | ||||
| 39 | + | func (s *sessionService) ListPasskeys(context.Context, *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) { | |||
| 40 | + | return nil, errPasskeysUnavailable | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (s *sessionService) RemovePasskey(context.Context, *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) { | |||
| 44 | + | return nil, errPasskeysUnavailable | |||
| 45 | + | } | |||
M internal/adapters/rpc/token.go +1 -0
| @@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb. | |||
| 34 | 34 | } | |
| 35 | 35 | params.Repos = append(params.Repos, name) | |
| 36 | 36 | } | |
| 37 | + | params.BranchPrefixes = req.Msg.GetBranchPrefixes() | |
| 37 | 38 | created, err := s.tokens.Create(ctx, caller(ctx), params) | |
| 38 | 39 | if err != nil { | |
| 39 | 40 | return nil, connectError(ctx, err) | |
| @@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb. | |||||
| 34 | } | 34 | } | ||
| 35 | params.Repos = append(params.Repos, name) | 35 | params.Repos = append(params.Repos, name) | ||
| 36 | } | 36 | } | ||
| 37 | + | params.BranchPrefixes = req.Msg.GetBranchPrefixes() | |||
| 37 | created, err := s.tokens.Create(ctx, caller(ctx), params) | 38 | created, err := s.tokens.Create(ctx, caller(ctx), params) | ||
| 38 | if err != nil { | 39 | if err != nil { | ||
| 39 | return nil, connectError(ctx, err) | 40 | return nil, connectError(ctx, err) | ||
M internal/adapters/rpc/token_test.go +31 -0
| @@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) { | |||
| 64 | 64 | require.NoError(t, err) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | func TestTokenServiceBranchPrefixes(t *testing.T) { | |
| 68 | + | t.Parallel() | |
| 69 | + | tokens := mocks.NewTokenService(t) | |
| 70 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool { | |
| 71 | + | return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes) | |
| 72 | + | })).Return(ports.CreatedToken{ | |
| 73 | + | Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}}, | |
| 74 | + | Secret: "s", | |
| 75 | + | }, nil).Once() | |
| 76 | + | resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |
| 77 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 78 | + | })) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes()) | |
| 81 | + | ||
| 82 | + | // A malformed prefix is the use case's invalid_token: invalid_argument. | |
| 83 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once() | |
| 84 | + | _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |
| 85 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"}, | |
| 86 | + | })) | |
| 87 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 88 | + | ||
| 89 | + | // Listing returns them too; a token without any has none. | |
| 90 | + | tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, ""). | |
| 91 | + | Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once() | |
| 92 | + | list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{})) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes()) | |
| 95 | + | assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes()) | |
| 96 | + | } | |
| 97 | + | ||
| 67 | 98 | func TestTokenServiceRevokeToken(t *testing.T) { | |
| 68 | 99 | t.Parallel() | |
| 69 | 100 | tests := []struct { | |
| @@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) { | |||||
| 64 | require.NoError(t, err) | 64 | require.NoError(t, err) | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | + | func TestTokenServiceBranchPrefixes(t *testing.T) { | |||
| 68 | + | t.Parallel() | |||
| 69 | + | tokens := mocks.NewTokenService(t) | |||
| 70 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool { | |||
| 71 | + | return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes) | |||
| 72 | + | })).Return(ports.CreatedToken{ | |||
| 73 | + | Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}}, | |||
| 74 | + | Secret: "s", | |||
| 75 | + | }, nil).Once() | |||
| 76 | + | resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |||
| 77 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |||
| 78 | + | })) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes()) | |||
| 81 | + | ||||
| 82 | + | // A malformed prefix is the use case's invalid_token: invalid_argument. | |||
| 83 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once() | |||
| 84 | + | _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |||
| 85 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"}, | |||
| 86 | + | })) | |||
| 87 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 88 | + | ||||
| 89 | + | // Listing returns them too; a token without any has none. | |||
| 90 | + | tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, ""). | |||
| 91 | + | Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once() | |||
| 92 | + | list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{})) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes()) | |||
| 95 | + | assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes()) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 67 | func TestTokenServiceRevokeToken(t *testing.T) { | 98 | func TestTokenServiceRevokeToken(t *testing.T) { | ||
| 68 | t.Parallel() | 99 | t.Parallel() | ||
| 69 | tests := []struct { | 100 | tests := []struct { | ||
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who) |
| 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
A internal/app/ci_branch_token_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "connectrpc.com/connect" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const everyBranchWorkflow = `name: all | |
| 17 | + | on: | |
| 18 | + | push: | |
| 19 | + | branches: ['**'] | |
| 20 | + | tags: ['v*'] | |
| 21 | + | jobs: | |
| 22 | + | j: | |
| 23 | + | runs-on: ubuntu-latest | |
| 24 | + | steps: | |
| 25 | + | - run: echo hi | |
| 26 | + | ` | |
| 27 | + | ||
| 28 | + | // A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and | |
| 29 | + | // logs is not a write and stays open to whoever can read the repository. | |
| 30 | + | // Cancelling and re-running a run is a write on the ref of the run (it stops or | |
| 31 | + | // repeats the workflow of that ref), so it follows the prefixes: allowed on a | |
| 32 | + | // branch under one, refused on any other branch and on a tag. Runners serve | |
| 33 | + | // every repository and branch, so a limited token never manages them, even with | |
| 34 | + | // runner:register. | |
| 35 | + | func TestCIBranchLimitedToken(t *testing.T) { | |
| 36 | + | c := newCIHarness(t, nil) | |
| 37 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 38 | + | wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow} | |
| 39 | + | ||
| 40 | + | c.push("main", wf, "") | |
| 41 | + | c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 }) | |
| 42 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main") | |
| 43 | + | c.push("agent/claude/x", nil, "") | |
| 44 | + | c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 }) | |
| 45 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch") | |
| 46 | + | c.mustGit(c.wt, "tag", "v1") | |
| 47 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1") | |
| 48 | + | c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 }) | |
| 49 | + | c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 50 | + | ||
| 51 | + | var mainRun, agentRun, tagRun *pb.Run | |
| 52 | + | for _, r := range c.runs() { | |
| 53 | + | switch r.GetRef() { | |
| 54 | + | case "refs/heads/main": | |
| 55 | + | mainRun = r | |
| 56 | + | case "refs/heads/agent/claude/x": | |
| 57 | + | agentRun = r | |
| 58 | + | case "refs/tags/v1": | |
| 59 | + | tagRun = r | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | require.NotNil(t, mainRun) | |
| 63 | + | require.NotNil(t, agentRun) | |
| 64 | + | require.NotNil(t, tagRun) | |
| 65 | + | ||
| 66 | + | all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister) | |
| 67 | + | secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj") | |
| 68 | + | limited := c.api(secret) | |
| 69 | + | // The same token without the limit, to prove the refusals come from it. | |
| 70 | + | unlimited := c.api(c.token(all, "proj")) | |
| 71 | + | cancel := func(api *clients, r *pb.Run) error { | |
| 72 | + | _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()})) | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | rerun := func(api *clients, r *pb.Run) error { | |
| 76 | + | _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()})) | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | t.Run("reads", func(t *testing.T) { | |
| 81 | + | list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"})) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes") | |
| 84 | + | got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()})) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | require.NotEmpty(t, got.Msg.GetJobs()) | |
| 87 | + | log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()})) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | var text string | |
| 90 | + | for log.Receive() { | |
| 91 | + | for _, l := range log.Msg().GetLines() { | |
| 92 | + | text += l.GetText() | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | require.NoError(t, log.Err()) | |
| 96 | + | assert.Contains(t, text, "log of main") | |
| 97 | + | _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()})) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | }) | |
| 100 | + | ||
| 101 | + | t.Run("writes outside the prefixes are refused", func(t *testing.T) { | |
| 102 | + | for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} { | |
| 103 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name) | |
| 104 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name) | |
| 105 | + | } | |
| 106 | + | // Still there: the refused rerun started no new attempt. | |
| 107 | + | assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt()) | |
| 108 | + | // Without the limit the same token is allowed (rerun) or reaches the | |
| 109 | + | // state rule of the run (cancel of a completed run). | |
| 110 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun))) | |
| 111 | + | require.NoError(t, rerun(unlimited, mainRun)) | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | t.Run("writes under the prefixes are allowed", func(t *testing.T) { | |
| 115 | + | require.NoError(t, rerun(limited, agentRun)) | |
| 116 | + | assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt()) | |
| 117 | + | require.NoError(t, cancel(limited, agentRun)) | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | t.Run("runners are out of reach", func(t *testing.T) { | |
| 121 | + | // Not restricted to a repository, so only the branch limit can refuse it. | |
| 122 | + | secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"}) | |
| 123 | + | limited := c.api(secret) | |
| 124 | + | _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{ | |
| 125 | + | Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true, | |
| 126 | + | })) | |
| 127 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register") | |
| 128 | + | _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |
| 129 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list") | |
| 130 | + | _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id})) | |
| 131 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove") | |
| 132 | + | ||
| 133 | + | // The same scopes without the limit manage runners (not restricted to a | |
| 134 | + | // repository, which the runner rules already demand). | |
| 135 | + | open := c.api(c.token(scopes(domain.ScopeRunnerRegister))) | |
| 136 | + | list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered") | |
| 139 | + | }) | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "connectrpc.com/connect" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const everyBranchWorkflow = `name: all | |||
| 17 | + | on: | |||
| 18 | + | push: | |||
| 19 | + | branches: ['**'] | |||
| 20 | + | tags: ['v*'] | |||
| 21 | + | jobs: | |||
| 22 | + | j: | |||
| 23 | + | runs-on: ubuntu-latest | |||
| 24 | + | steps: | |||
| 25 | + | - run: echo hi | |||
| 26 | + | ` | |||
| 27 | + | ||||
| 28 | + | // A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and | |||
| 29 | + | // logs is not a write and stays open to whoever can read the repository. | |||
| 30 | + | // Cancelling and re-running a run is a write on the ref of the run (it stops or | |||
| 31 | + | // repeats the workflow of that ref), so it follows the prefixes: allowed on a | |||
| 32 | + | // branch under one, refused on any other branch and on a tag. Runners serve | |||
| 33 | + | // every repository and branch, so a limited token never manages them, even with | |||
| 34 | + | // runner:register. | |||
| 35 | + | func TestCIBranchLimitedToken(t *testing.T) { | |||
| 36 | + | c := newCIHarness(t, nil) | |||
| 37 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 38 | + | wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow} | |||
| 39 | + | ||||
| 40 | + | c.push("main", wf, "") | |||
| 41 | + | c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 }) | |||
| 42 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main") | |||
| 43 | + | c.push("agent/claude/x", nil, "") | |||
| 44 | + | c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 }) | |||
| 45 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch") | |||
| 46 | + | c.mustGit(c.wt, "tag", "v1") | |||
| 47 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1") | |||
| 48 | + | c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 }) | |||
| 49 | + | c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |||
| 50 | + | ||||
| 51 | + | var mainRun, agentRun, tagRun *pb.Run | |||
| 52 | + | for _, r := range c.runs() { | |||
| 53 | + | switch r.GetRef() { | |||
| 54 | + | case "refs/heads/main": | |||
| 55 | + | mainRun = r | |||
| 56 | + | case "refs/heads/agent/claude/x": | |||
| 57 | + | agentRun = r | |||
| 58 | + | case "refs/tags/v1": | |||
| 59 | + | tagRun = r | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | require.NotNil(t, mainRun) | |||
| 63 | + | require.NotNil(t, agentRun) | |||
| 64 | + | require.NotNil(t, tagRun) | |||
| 65 | + | ||||
| 66 | + | all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister) | |||
| 67 | + | secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj") | |||
| 68 | + | limited := c.api(secret) | |||
| 69 | + | // The same token without the limit, to prove the refusals come from it. | |||
| 70 | + | unlimited := c.api(c.token(all, "proj")) | |||
| 71 | + | cancel := func(api *clients, r *pb.Run) error { | |||
| 72 | + | _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()})) | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | rerun := func(api *clients, r *pb.Run) error { | |||
| 76 | + | _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()})) | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | t.Run("reads", func(t *testing.T) { | |||
| 81 | + | list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"})) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes") | |||
| 84 | + | got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()})) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | require.NotEmpty(t, got.Msg.GetJobs()) | |||
| 87 | + | log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()})) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | var text string | |||
| 90 | + | for log.Receive() { | |||
| 91 | + | for _, l := range log.Msg().GetLines() { | |||
| 92 | + | text += l.GetText() | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | require.NoError(t, log.Err()) | |||
| 96 | + | assert.Contains(t, text, "log of main") | |||
| 97 | + | _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()})) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | }) | |||
| 100 | + | ||||
| 101 | + | t.Run("writes outside the prefixes are refused", func(t *testing.T) { | |||
| 102 | + | for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} { | |||
| 103 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name) | |||
| 104 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name) | |||
| 105 | + | } | |||
| 106 | + | // Still there: the refused rerun started no new attempt. | |||
| 107 | + | assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt()) | |||
| 108 | + | // Without the limit the same token is allowed (rerun) or reaches the | |||
| 109 | + | // state rule of the run (cancel of a completed run). | |||
| 110 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun))) | |||
| 111 | + | require.NoError(t, rerun(unlimited, mainRun)) | |||
| 112 | + | }) | |||
| 113 | + | ||||
| 114 | + | t.Run("writes under the prefixes are allowed", func(t *testing.T) { | |||
| 115 | + | require.NoError(t, rerun(limited, agentRun)) | |||
| 116 | + | assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt()) | |||
| 117 | + | require.NoError(t, cancel(limited, agentRun)) | |||
| 118 | + | }) | |||
| 119 | + | ||||
| 120 | + | t.Run("runners are out of reach", func(t *testing.T) { | |||
| 121 | + | // Not restricted to a repository, so only the branch limit can refuse it. | |||
| 122 | + | secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"}) | |||
| 123 | + | limited := c.api(secret) | |||
| 124 | + | _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{ | |||
| 125 | + | Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true, | |||
| 126 | + | })) | |||
| 127 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register") | |||
| 128 | + | _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |||
| 129 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list") | |||
| 130 | + | _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id})) | |||
| 131 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove") | |||
| 132 | + | ||||
| 133 | + | // The same scopes without the limit manage runners (not restricted to a | |||
| 134 | + | // repository, which the runner rules already demand). | |||
| 135 | + | open := c.api(c.token(scopes(domain.ScopeRunnerRegister))) | |||
| 136 | + | list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{})) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered") | |||
| 139 | + | }) | |||
| 140 | + | } | |||
M internal/app/cli_e2e_test.go +3 -3
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 324 | 324 | ver := decode[versionOut](t, res.stdout) | |
| 325 | 325 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 326 | assert.NotEmpty(t, ver.Server.Version) | |
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features) | |
| 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features) | |
| 328 | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 329 | 329 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 330 | require.Equal(t, 0, code, stderr) | |
| @@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 336 | 336 | stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json") | |
| 337 | 337 | require.Equal(t, 0, code, stderr) | |
| 338 | 338 | res = result{stdout: stdout} | |
| 339 | - | assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | |
| 339 | + | assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | |
| 340 | 340 | tmp := decode[tokenOut](t, res.stdout) | |
| 341 | 341 | assert.Equal(t, []string{"repo:read"}, tmp.Scopes) | |
| 342 | 342 | assert.Equal(t, []string{"proj"}, tmp.Repos) | |
| @@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 366 | 366 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | |
| 367 | 367 | assert.NotContains(t, res.stdout, `"secret"`) | |
| 368 | 368 | res = c.cli(t, secret, "token", "list").ok(t) | |
| 369 | - | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout) | |
| 369 | + | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout) | |
| 370 | 370 | assert.Contains(t, res.stdout, tmp.ID) | |
| 371 | 371 | ||
| 372 | 372 | stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID) | |
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 324 | ver := decode[versionOut](t, res.stdout) | 324 | ver := decode[versionOut](t, res.stdout) | ||
| 325 | assert.Equal(t, c.url, ver.Server.URL) | 325 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 326 | assert.NotEmpty(t, ver.Server.Version) | 326 | assert.NotEmpty(t, ver.Server.Version) | ||
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features) | 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features) |
| 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | ||
| 329 | stdout, stderr, code = c.bin(t, secret, "version") | 329 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 330 | require.Equal(t, 0, code, stderr) | 330 | require.Equal(t, 0, code, stderr) | ||
| @@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 336 | stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json") | 336 | stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json") | ||
| 337 | require.Equal(t, 0, code, stderr) | 337 | require.Equal(t, 0, code, stderr) | ||
| 338 | res = result{stdout: stdout} | 338 | res = result{stdout: stdout} | ||
| 339 | - | assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) | 339 | + | assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout)) |
| 340 | tmp := decode[tokenOut](t, res.stdout) | 340 | tmp := decode[tokenOut](t, res.stdout) | ||
| 341 | assert.Equal(t, []string{"repo:read"}, tmp.Scopes) | 341 | assert.Equal(t, []string{"repo:read"}, tmp.Scopes) | ||
| 342 | assert.Equal(t, []string{"proj"}, tmp.Repos) | 342 | assert.Equal(t, []string{"proj"}, tmp.Repos) | ||
| @@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 366 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | 366 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | ||
| 367 | assert.NotContains(t, res.stdout, `"secret"`) | 367 | assert.NotContains(t, res.stdout, `"secret"`) | ||
| 368 | res = c.cli(t, secret, "token", "list").ok(t) | 368 | res = c.cli(t, secret, "token", "list").ok(t) | ||
| 369 | - | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout) | 369 | + | assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout) |
| 370 | assert.Contains(t, res.stdout, tmp.ID) | 370 | assert.Contains(t, res.stdout, tmp.ID) | ||
| 371 | 371 | ||||
| 372 | stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID) | 372 | stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID) | ||
M internal/app/cli_token.go +81 -19
| @@ -21,13 +21,15 @@ import ( | |||
| 21 | 21 | // jsonToken is the stable JSON shape of a stored token. The secret is only | |
| 22 | 22 | // present in the output of `token create`. | |
| 23 | 23 | type jsonToken struct { | |
| 24 | - | ID string `json:"id"` | |
| 25 | - | Label string `json:"label"` | |
| 26 | - | Scopes []string `json:"scopes"` | |
| 27 | - | Repos []string `json:"repos"` | |
| 28 | - | ExpiresAt *string `json:"expires_at"` | |
| 29 | - | CreatedAt *string `json:"created_at"` | |
| 30 | - | LastUsedAt *string `json:"last_used_at"` | |
| 24 | + | ID string `json:"id"` | |
| 25 | + | Label string `json:"label"` | |
| 26 | + | Scopes []string `json:"scopes"` | |
| 27 | + | Repos []string `json:"repos"` | |
| 28 | + | // BranchPrefixes limits the branches the token may write; empty means all. | |
| 29 | + | BranchPrefixes []string `json:"branch_prefixes"` | |
| 30 | + | ExpiresAt *string `json:"expires_at"` | |
| 31 | + | CreatedAt *string `json:"created_at"` | |
| 32 | + | LastUsedAt *string `json:"last_used_at"` | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | 35 | type jsonCreatedToken struct { | |
| @@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken { | |||
| 44 | 46 | if repos == nil { | |
| 45 | 47 | repos = []string{} | |
| 46 | 48 | } | |
| 49 | + | prefixes := t.GetBranchPrefixes() | |
| 50 | + | if prefixes == nil { | |
| 51 | + | prefixes = []string{} | |
| 52 | + | } | |
| 47 | 53 | return jsonToken{ | |
| 48 | - | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, | |
| 54 | + | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes, | |
| 49 | 55 | ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()), | |
| 50 | 56 | } | |
| 51 | 57 | } | |
| 52 | 58 | ||
| 53 | 59 | const jsonTokenHelp = ` | |
| 54 | 60 | JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"), | |
| 55 | - | repos (array; empty means every repository), expires_at, created_at and last_used_at | |
| 56 | - | (RFC 3339 UTC, null when unset or never used).` | |
| 61 | + | repos (array; empty means every repository), branch_prefixes (array; empty means every branch), | |
| 62 | + | expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).` | |
| 57 | 63 | ||
| 58 | 64 | func newTokenCmd(env *cliEnv) *cobra.Command { | |
| 59 | 65 | token := &cobra.Command{ | |
| @@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command { | |||
| 67 | 73 | ||
| 68 | 74 | func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |
| 69 | 75 | var ( | |
| 70 | - | preset string | |
| 71 | - | scopes []string | |
| 72 | - | repos []string | |
| 73 | - | expires string | |
| 76 | + | preset string | |
| 77 | + | scopes []string | |
| 78 | + | repos []string | |
| 79 | + | prefixes []string | |
| 80 | + | expires string | |
| 74 | 81 | ) | |
| 75 | 82 | cmd := &cobra.Command{ | |
| 76 | 83 | Use: "create <label>", | |
| @@ -85,6 +92,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 85 | 92 | " ci repo:read\n" + | |
| 86 | 93 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | |
| 87 | 94 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | |
| 95 | + | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | |
| 96 | + | "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" + | |
| 97 | + | "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" + | |
| 98 | + | "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" + | |
| 99 | + | "To give an AI agent its own space:\n" + | |
| 100 | + | " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" + | |
| 88 | 101 | "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" + | |
| 89 | 102 | "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp, | |
| 90 | 103 | Args: cobra.ExactArgs(1), | |
| @@ -92,6 +105,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 92 | 105 | cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", ")) | |
| 93 | 106 | cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)") | |
| 94 | 107 | cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)") | |
| 108 | + | cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)") | |
| 95 | 109 | cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never") | |
| 96 | 110 | _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 97 | 111 | return client.PresetNames, cobra.ShellCompDirectiveNoFileComp | |
| @@ -104,7 +118,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 104 | 118 | }) | |
| 105 | 119 | asJSON := addJSONFlag(cmd) | |
| 106 | 120 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 107 | - | req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now()) | |
| 121 | + | req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now()) | |
| 108 | 122 | if err != nil { | |
| 109 | 123 | return err | |
| 110 | 124 | } | |
| @@ -112,17 +126,51 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 112 | 126 | if err != nil { | |
| 113 | 127 | return err | |
| 114 | 128 | } | |
| 129 | + | if len(req.GetBranchPrefixes()) > 0 { | |
| 130 | + | if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | } | |
| 115 | 134 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | |
| 116 | 135 | if err != nil { | |
| 117 | 136 | return c.Describe(err) | |
| 118 | 137 | } | |
| 138 | + | if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil { | |
| 139 | + | return err | |
| 140 | + | } | |
| 119 | 141 | return printCreatedToken(cmd, res.Msg, *asJSON) | |
| 120 | 142 | } | |
| 121 | 143 | return cmd | |
| 122 | 144 | } | |
| 123 | 145 | ||
| 146 | + | // requireBranchPrefixes refuses, before anything is created, to ask a server | |
| 147 | + | // for a branch-limited token when it does not list token.branch_prefixes: such | |
| 148 | + | // a server ignores the field. A server whose features cannot be read is refused | |
| 149 | + | // too (fail closed). | |
| 150 | + | func requireBranchPrefixes(ctx context.Context, c *client.Client) error { | |
| 151 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |
| 152 | + | if err != nil { | |
| 153 | + | return c.Describe(err) | |
| 154 | + | } | |
| 155 | + | if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) { | |
| 156 | + | return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes) | |
| 157 | + | } | |
| 158 | + | return nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than | |
| 162 | + | // asked for. A server that predates branch prefixes ignores the field and | |
| 163 | + | // creates an unlimited token: the secret is then not printed. | |
| 164 | + | func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error { | |
| 165 | + | if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) { | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+ | |
| 169 | + | "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId()) | |
| 170 | + | } | |
| 171 | + | ||
| 124 | 172 | // buildCreateToken maps the flags of `token create` to the request. | |
| 125 | - | func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | |
| 173 | + | func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | |
| 126 | 174 | label = strings.TrimSpace(label) | |
| 127 | 175 | if label == "" { | |
| 128 | 176 | return nil, errors.New("the label must not be empty") | |
| @@ -137,6 +185,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi | |||
| 137 | 185 | req.Repos = append(req.Repos, r) | |
| 138 | 186 | } | |
| 139 | 187 | } | |
| 188 | + | for _, bp := range prefixFlags { | |
| 189 | + | if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) { | |
| 190 | + | req.BranchPrefixes = append(req.BranchPrefixes, bp) | |
| 191 | + | } | |
| 192 | + | } | |
| 140 | 193 | if expires != "" { | |
| 141 | 194 | at, err := client.ParseExpiry(expires, now) | |
| 142 | 195 | if err != nil { | |
| @@ -156,10 +209,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS | |||
| 156 | 209 | if len(t.GetRepos()) > 0 { | |
| 157 | 210 | repos = strings.Join(t.GetRepos(), ", ") | |
| 158 | 211 | } | |
| 212 | + | branches := "all" | |
| 213 | + | if len(t.GetBranchPrefixes()) > 0 { | |
| 214 | + | branches = strings.Join(t.GetBranchPrefixes(), ", ") | |
| 215 | + | } | |
| 159 | 216 | noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel())) | |
| 160 | 217 | _ = fields(cmd.ErrOrStderr(), [][2]string{ | |
| 161 | 218 | {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")}, | |
| 162 | 219 | {" repos", repos}, | |
| 220 | + | {" branches", branches}, | |
| 163 | 221 | {" expires", shortTime(t.GetExpiresAt(), "never")}, | |
| 164 | 222 | }) | |
| 165 | 223 | noticef(cmd, "the secret below is shown once and cannot be retrieved later") | |
| @@ -211,12 +269,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||
| 211 | 269 | if len(t.GetRepos()) > 0 { | |
| 212 | 270 | repos = strings.Join(t.GetRepos(), ",") | |
| 213 | 271 | } | |
| 272 | + | branches := "all" | |
| 273 | + | if len(t.GetBranchPrefixes()) > 0 { | |
| 274 | + | branches = strings.Join(t.GetBranchPrefixes(), ",") | |
| 275 | + | } | |
| 214 | 276 | rows = append(rows, []string{ | |
| 215 | - | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, | |
| 277 | + | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches, | |
| 216 | 278 | shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"), | |
| 217 | 279 | }) | |
| 218 | 280 | } | |
| 219 | - | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows) | |
| 281 | + | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows) | |
| 220 | 282 | } | |
| 221 | 283 | return cmd | |
| 222 | 284 | } | |
| @@ -226,7 +288,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command { | |||
| 226 | 288 | Use: "revoke <id>", | |
| 227 | 289 | Short: "Revoke a token", | |
| 228 | 290 | Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" + | |
| 229 | - | "A token with every scope and no --repo list has the rights of the owner (it can open web\n" + | |
| 291 | + | "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" + | |
| 230 | 292 | "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" + | |
| 231 | 293 | "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.", | |
| 232 | 294 | Args: cobra.ExactArgs(1), | |
| @@ -21,13 +21,15 @@ import ( | |||||
| 21 | // jsonToken is the stable JSON shape of a stored token. The secret is only | 21 | // jsonToken is the stable JSON shape of a stored token. The secret is only | ||
| 22 | // present in the output of `token create`. | 22 | // present in the output of `token create`. | ||
| 23 | type jsonToken struct { | 23 | type jsonToken struct { | ||
| 24 | - | ID string `json:"id"` | 24 | + | ID string `json:"id"` |
| 25 | - | Label string `json:"label"` | 25 | + | Label string `json:"label"` |
| 26 | - | Scopes []string `json:"scopes"` | 26 | + | Scopes []string `json:"scopes"` |
| 27 | - | Repos []string `json:"repos"` | 27 | + | Repos []string `json:"repos"` |
| 28 | - | ExpiresAt *string `json:"expires_at"` | 28 | + | // BranchPrefixes limits the branches the token may write; empty means all. |
| 29 | - | CreatedAt *string `json:"created_at"` | 29 | + | BranchPrefixes []string `json:"branch_prefixes"` |
| 30 | - | LastUsedAt *string `json:"last_used_at"` | 30 | + | ExpiresAt *string `json:"expires_at"` |
| 31 | + | CreatedAt *string `json:"created_at"` | |||
| 32 | + | LastUsedAt *string `json:"last_used_at"` | |||
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| 33 | type jsonCreatedToken struct { | 35 | type jsonCreatedToken struct { | ||
| @@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken { | |||||
| 44 | if repos == nil { | 46 | if repos == nil { | ||
| 45 | repos = []string{} | 47 | repos = []string{} | ||
| 46 | } | 48 | } | ||
| 49 | + | prefixes := t.GetBranchPrefixes() | |||
| 50 | + | if prefixes == nil { | |||
| 51 | + | prefixes = []string{} | |||
| 52 | + | } | |||
| 47 | return jsonToken{ | 53 | return jsonToken{ | ||
| 48 | - | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, | 54 | + | ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes, |
| 49 | ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()), | 55 | ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()), | ||
| 50 | } | 56 | } | ||
| 51 | } | 57 | } | ||
| 52 | 58 | ||||
| 53 | const jsonTokenHelp = ` | 59 | const jsonTokenHelp = ` | ||
| 54 | JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"), | 60 | JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"), | ||
| 55 | - | repos (array; empty means every repository), expires_at, created_at and last_used_at | 61 | + | repos (array; empty means every repository), branch_prefixes (array; empty means every branch), |
| 56 | - | (RFC 3339 UTC, null when unset or never used).` | 62 | + | expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).` |
| 57 | 63 | ||||
| 58 | func newTokenCmd(env *cliEnv) *cobra.Command { | 64 | func newTokenCmd(env *cliEnv) *cobra.Command { | ||
| 59 | token := &cobra.Command{ | 65 | token := &cobra.Command{ | ||
| @@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command { | |||||
| 67 | 73 | ||||
| 68 | func newTokenCreateCmd(env *cliEnv) *cobra.Command { | 74 | func newTokenCreateCmd(env *cliEnv) *cobra.Command { | ||
| 69 | var ( | 75 | var ( | ||
| 70 | - | preset string | 76 | + | preset string |
| 71 | - | scopes []string | 77 | + | scopes []string |
| 72 | - | repos []string | 78 | + | repos []string |
| 73 | - | expires string | 79 | + | prefixes []string |
| 80 | + | expires string | |||
| 74 | ) | 81 | ) | ||
| 75 | cmd := &cobra.Command{ | 82 | cmd := &cobra.Command{ | ||
| 76 | Use: "create <label>", | 83 | Use: "create <label>", | ||
| @@ -85,6 +92,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 85 | " ci repo:read\n" + | 92 | " ci repo:read\n" + | ||
| 86 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | 93 | "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" + | ||
| 87 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | 94 | "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" + | ||
| 95 | + | "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" + | |||
| 96 | + | "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" + | |||
| 97 | + | "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" + | |||
| 98 | + | "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" + | |||
| 99 | + | "To give an AI agent its own space:\n" + | |||
| 100 | + | " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" + | |||
| 88 | "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" + | 101 | "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" + | ||
| 89 | "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp, | 102 | "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp, | ||
| 90 | Args: cobra.ExactArgs(1), | 103 | Args: cobra.ExactArgs(1), | ||
| @@ -92,6 +105,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 92 | cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", ")) | 105 | cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", ")) | ||
| 93 | cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)") | 106 | cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)") | ||
| 94 | cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)") | 107 | cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)") | ||
| 108 | + | cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)") | |||
| 95 | cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never") | 109 | cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never") | ||
| 96 | _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 110 | _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| 97 | return client.PresetNames, cobra.ShellCompDirectiveNoFileComp | 111 | return client.PresetNames, cobra.ShellCompDirectiveNoFileComp | ||
| @@ -104,7 +118,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 104 | }) | 118 | }) | ||
| 105 | asJSON := addJSONFlag(cmd) | 119 | asJSON := addJSONFlag(cmd) | ||
| 106 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 120 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 107 | - | req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now()) | 121 | + | req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now()) |
| 108 | if err != nil { | 122 | if err != nil { | ||
| 109 | return err | 123 | return err | ||
| 110 | } | 124 | } | ||
| @@ -112,17 +126,51 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 112 | if err != nil { | 126 | if err != nil { | ||
| 113 | return err | 127 | return err | ||
| 114 | } | 128 | } | ||
| 129 | + | if len(req.GetBranchPrefixes()) > 0 { | |||
| 130 | + | if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 115 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | 134 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | ||
| 116 | if err != nil { | 135 | if err != nil { | ||
| 117 | return c.Describe(err) | 136 | return c.Describe(err) | ||
| 118 | } | 137 | } | ||
| 138 | + | if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 119 | return printCreatedToken(cmd, res.Msg, *asJSON) | 141 | return printCreatedToken(cmd, res.Msg, *asJSON) | ||
| 120 | } | 142 | } | ||
| 121 | return cmd | 143 | return cmd | ||
| 122 | } | 144 | } | ||
| 123 | 145 | ||||
| 146 | + | // requireBranchPrefixes refuses, before anything is created, to ask a server | |||
| 147 | + | // for a branch-limited token when it does not list token.branch_prefixes: such | |||
| 148 | + | // a server ignores the field. A server whose features cannot be read is refused | |||
| 149 | + | // too (fail closed). | |||
| 150 | + | func requireBranchPrefixes(ctx context.Context, c *client.Client) error { | |||
| 151 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return c.Describe(err) | |||
| 154 | + | } | |||
| 155 | + | if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) { | |||
| 156 | + | return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes) | |||
| 157 | + | } | |||
| 158 | + | return nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than | |||
| 162 | + | // asked for. A server that predates branch prefixes ignores the field and | |||
| 163 | + | // creates an unlimited token: the secret is then not printed. | |||
| 164 | + | func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error { | |||
| 165 | + | if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) { | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+ | |||
| 169 | + | "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId()) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 124 | // buildCreateToken maps the flags of `token create` to the request. | 172 | // buildCreateToken maps the flags of `token create` to the request. | ||
| 125 | - | func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { | 173 | + | func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) { |
| 126 | label = strings.TrimSpace(label) | 174 | label = strings.TrimSpace(label) | ||
| 127 | if label == "" { | 175 | if label == "" { | ||
| 128 | return nil, errors.New("the label must not be empty") | 176 | return nil, errors.New("the label must not be empty") | ||
| @@ -137,6 +185,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi | |||||
| 137 | req.Repos = append(req.Repos, r) | 185 | req.Repos = append(req.Repos, r) | ||
| 138 | } | 186 | } | ||
| 139 | } | 187 | } | ||
| 188 | + | for _, bp := range prefixFlags { | |||
| 189 | + | if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) { | |||
| 190 | + | req.BranchPrefixes = append(req.BranchPrefixes, bp) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 140 | if expires != "" { | 193 | if expires != "" { | ||
| 141 | at, err := client.ParseExpiry(expires, now) | 194 | at, err := client.ParseExpiry(expires, now) | ||
| 142 | if err != nil { | 195 | if err != nil { | ||
| @@ -156,10 +209,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS | |||||
| 156 | if len(t.GetRepos()) > 0 { | 209 | if len(t.GetRepos()) > 0 { | ||
| 157 | repos = strings.Join(t.GetRepos(), ", ") | 210 | repos = strings.Join(t.GetRepos(), ", ") | ||
| 158 | } | 211 | } | ||
| 212 | + | branches := "all" | |||
| 213 | + | if len(t.GetBranchPrefixes()) > 0 { | |||
| 214 | + | branches = strings.Join(t.GetBranchPrefixes(), ", ") | |||
| 215 | + | } | |||
| 159 | noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel())) | 216 | noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel())) | ||
| 160 | _ = fields(cmd.ErrOrStderr(), [][2]string{ | 217 | _ = fields(cmd.ErrOrStderr(), [][2]string{ | ||
| 161 | {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")}, | 218 | {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")}, | ||
| 162 | {" repos", repos}, | 219 | {" repos", repos}, | ||
| 220 | + | {" branches", branches}, | |||
| 163 | {" expires", shortTime(t.GetExpiresAt(), "never")}, | 221 | {" expires", shortTime(t.GetExpiresAt(), "never")}, | ||
| 164 | }) | 222 | }) | ||
| 165 | noticef(cmd, "the secret below is shown once and cannot be retrieved later") | 223 | noticef(cmd, "the secret below is shown once and cannot be retrieved later") | ||
| @@ -211,12 +269,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command { | |||||
| 211 | if len(t.GetRepos()) > 0 { | 269 | if len(t.GetRepos()) > 0 { | ||
| 212 | repos = strings.Join(t.GetRepos(), ",") | 270 | repos = strings.Join(t.GetRepos(), ",") | ||
| 213 | } | 271 | } | ||
| 272 | + | branches := "all" | |||
| 273 | + | if len(t.GetBranchPrefixes()) > 0 { | |||
| 274 | + | branches = strings.Join(t.GetBranchPrefixes(), ",") | |||
| 275 | + | } | |||
| 214 | rows = append(rows, []string{ | 276 | rows = append(rows, []string{ | ||
| 215 | - | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, | 277 | + | t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches, |
| 216 | shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"), | 278 | shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"), | ||
| 217 | }) | 279 | }) | ||
| 218 | } | 280 | } | ||
| 219 | - | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows) | 281 | + | return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows) |
| 220 | } | 282 | } | ||
| 221 | return cmd | 283 | return cmd | ||
| 222 | } | 284 | } | ||
| @@ -226,7 +288,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command { | |||||
| 226 | Use: "revoke <id>", | 288 | Use: "revoke <id>", | ||
| 227 | Short: "Revoke a token", | 289 | Short: "Revoke a token", | ||
| 228 | Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" + | 290 | Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" + | ||
| 229 | - | "A token with every scope and no --repo list has the rights of the owner (it can open web\n" + | 291 | + | "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" + |
| 230 | "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" + | 292 | "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" + | ||
| 231 | "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.", | 293 | "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.", | ||
| 232 | Args: cobra.ExactArgs(1), | 294 | Args: cobra.ExactArgs(1), | ||
M internal/app/cli_token_test.go +131 -6
| @@ -3,9 +3,12 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 7 | + | "strings" | |
| 6 | 8 | "testing" | |
| 7 | 9 | "time" | |
| 8 | 10 | ||
| 11 | + | "connectrpc.com/connect" | |
| 9 | 12 | "github.com/stretchr/testify/assert" | |
| 10 | 13 | "github.com/stretchr/testify/require" | |
| 11 | 14 | ||
| @@ -42,6 +45,128 @@ func TestTokenCreateMapsFlags(t *testing.T) { | |||
| 42 | 45 | assert.NotContains(t, res.stderr, "SECRETVALUE") | |
| 43 | 46 | } | |
| 44 | 47 | ||
| 48 | + | // lastCreateToken returns the last CreateToken request the fake received. | |
| 49 | + | func lastCreateToken(t *testing.T, f *fakeAPI) *ermite.CreateTokenRequest { | |
| 50 | + | t.Helper() | |
| 51 | + | calls, reqs := f.snapshot() | |
| 52 | + | for i := len(calls) - 1; i >= 0; i-- { | |
| 53 | + | if calls[i] == "CreateToken" { | |
| 54 | + | return reqs[i].(*ermite.CreateTokenRequest) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | t.Fatal("no CreateToken call") | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // withBranchPrefixes is a GetVersion that lists token.branch_prefixes. | |
| 62 | + | func withBranchPrefixes(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 63 | + | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"token.branch_prefixes"}}, nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestTokenCreateBranchPrefixes(t *testing.T) { | |
| 67 | + | echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 68 | + | return &ermite.CreateTokenResponse{ | |
| 69 | + | Token: &ermite.Token{ | |
| 70 | + | Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(), | |
| 71 | + | CreatedAt: ts("2026-10-10T12:00:00Z"), | |
| 72 | + | }, | |
| 73 | + | Secret: "ermite_S", | |
| 74 | + | }, nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) { | |
| 78 | + | f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes} | |
| 79 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha", | |
| 80 | + | "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t) | |
| 81 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, lastCreateToken(t, f).GetBranchPrefixes()) | |
| 82 | + | assert.Equal(t, "ermite_S\n", res.stdout) | |
| 83 | + | assert.Contains(t, res.stderr, "branches") | |
| 84 | + | assert.Contains(t, res.stderr, "agent/claude/, bot/") | |
| 85 | + | }) | |
| 86 | + | ||
| 87 | + | t.Run("without the flag the token shows every branch", func(t *testing.T) { | |
| 88 | + | f := &fakeAPI{createToken: echo} | |
| 89 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |
| 90 | + | _, reqs := f.snapshot() | |
| 91 | + | assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes()) | |
| 92 | + | assert.Regexp(t, `branches\s+all`, res.stderr) | |
| 93 | + | }) | |
| 94 | + | ||
| 95 | + | t.Run("json", func(t *testing.T) { | |
| 96 | + | f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes} | |
| 97 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t) | |
| 98 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 99 | + | }) | |
| 100 | + | ||
| 101 | + | t.Run("a server without the feature is asked nothing", func(t *testing.T) { | |
| 102 | + | for _, tt := range []struct { | |
| 103 | + | name string | |
| 104 | + | version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) | |
| 105 | + | wantMsg string | |
| 106 | + | }{ | |
| 107 | + | {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 108 | + | return &ermite.GetVersionResponse{Version: "v0.3.0", Features: []string{"repo.update", "session"}}, nil | |
| 109 | + | }, "does not support token.branch_prefixes"}, | |
| 110 | + | {"version unreadable", nil, "does not implement this call"}, // the fake answers unimplemented | |
| 111 | + | {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 112 | + | return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused")) | |
| 113 | + | }, "cannot reach the server"}, | |
| 114 | + | } { | |
| 115 | + | f := &fakeAPI{createToken: echo, getVersion: tt.version} | |
| 116 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |
| 117 | + | require.Error(t, res.err, tt.name) | |
| 118 | + | assert.Contains(t, strings.ToLower(res.err.Error()), tt.wantMsg, tt.name) | |
| 119 | + | calls, _ := f.snapshot() | |
| 120 | + | assert.NotContains(t, calls, "CreateToken", "%s: no token is created", tt.name) | |
| 121 | + | assert.Empty(t, res.stdout, tt.name) | |
| 122 | + | } | |
| 123 | + | }) | |
| 124 | + | ||
| 125 | + | t.Run("without --branch-prefix the features are not read", func(t *testing.T) { | |
| 126 | + | f := &fakeAPI{createToken: echo} // no getVersion: it would answer unimplemented | |
| 127 | + | runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |
| 128 | + | calls, _ := f.snapshot() | |
| 129 | + | assert.NotContains(t, calls, "GetVersion") | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) { | |
| 133 | + | f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 134 | + | return &ermite.CreateTokenResponse{ | |
| 135 | + | Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")}, | |
| 136 | + | Secret: "ermite_UNLIMITED", | |
| 137 | + | }, nil | |
| 138 | + | }, getVersion: withBranchPrefixes} | |
| 139 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |
| 140 | + | require.Error(t, res.err) | |
| 141 | + | assert.Contains(t, res.err.Error(), "without the requested branch prefixes") | |
| 142 | + | assert.Contains(t, res.err.Error(), "ermite token revoke tok9") | |
| 143 | + | assert.NotContains(t, res.stdout, "UNLIMITED") | |
| 144 | + | assert.NotContains(t, res.stderr, "UNLIMITED") | |
| 145 | + | assert.NotContains(t, res.err.Error(), "UNLIMITED") | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestTokenListBranchPrefixes(t *testing.T) { | |
| 150 | + | f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) { | |
| 151 | + | return &ermite.ListTokensResponse{Tokens: []*ermite.Token{ | |
| 152 | + | {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")}, | |
| 153 | + | {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")}, | |
| 154 | + | }}, nil | |
| 155 | + | }} | |
| 156 | + | url := f.serve(t) | |
| 157 | + | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |
| 158 | + | assert.Equal(t, ""+ | |
| 159 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |
| 160 | + | "t2 claude repo:write all agent/claude/,bot/ never never\n"+ | |
| 161 | + | "t1 ci repo:read all all never never\n", res.stdout) | |
| 162 | + | ||
| 163 | + | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |
| 164 | + | assert.JSONEq(t, `[ | |
| 165 | + | {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null}, | |
| 166 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 167 | + | ]`, res.stdout) | |
| 168 | + | } | |
| 169 | + | ||
| 45 | 170 | func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | |
| 46 | 171 | for preset, want := range map[string][]ermite.Scope{ | |
| 47 | 172 | "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, | |
| @@ -70,7 +195,7 @@ func TestTokenCreateJSON(t *testing.T) { | |||
| 70 | 195 | }, nil | |
| 71 | 196 | }} | |
| 72 | 197 | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t) | |
| 73 | - | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 198 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |
| 74 | 199 | assert.Empty(t, res.stderr) | |
| 75 | 200 | } | |
| 76 | 201 | ||
| @@ -106,14 +231,14 @@ func TestTokenList(t *testing.T) { | |||
| 106 | 231 | url := f.serve(t) | |
| 107 | 232 | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |
| 108 | 233 | assert.Equal(t, ""+ | |
| 109 | - | "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+ | |
| 110 | - | "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+ | |
| 111 | - | "t1 ci repo:read all never never\n", res.stdout) | |
| 234 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |
| 235 | + | "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+ | |
| 236 | + | "t1 ci repo:read all all never never\n", res.stdout) | |
| 112 | 237 | ||
| 113 | 238 | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |
| 114 | 239 | assert.JSONEq(t, `[ | |
| 115 | - | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | |
| 116 | - | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 240 | + | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | |
| 241 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |
| 117 | 242 | ]`, res.stdout) | |
| 118 | 243 | assert.NotContains(t, res.stdout, "secret") | |
| 119 | 244 | } | |
| @@ -3,9 +3,12 @@ | |||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "errors" | |||
| 7 | + | "strings" | |||
| 6 | "testing" | 8 | "testing" | ||
| 7 | "time" | 9 | "time" | ||
| 8 | 10 | ||||
| 11 | + | "connectrpc.com/connect" | |||
| 9 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 11 | 14 | ||||
| @@ -42,6 +45,128 @@ func TestTokenCreateMapsFlags(t *testing.T) { | |||||
| 42 | assert.NotContains(t, res.stderr, "SECRETVALUE") | 45 | assert.NotContains(t, res.stderr, "SECRETVALUE") | ||
| 43 | } | 46 | } | ||
| 44 | 47 | ||||
| 48 | + | // lastCreateToken returns the last CreateToken request the fake received. | |||
| 49 | + | func lastCreateToken(t *testing.T, f *fakeAPI) *ermite.CreateTokenRequest { | |||
| 50 | + | t.Helper() | |||
| 51 | + | calls, reqs := f.snapshot() | |||
| 52 | + | for i := len(calls) - 1; i >= 0; i-- { | |||
| 53 | + | if calls[i] == "CreateToken" { | |||
| 54 | + | return reqs[i].(*ermite.CreateTokenRequest) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | t.Fatal("no CreateToken call") | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // withBranchPrefixes is a GetVersion that lists token.branch_prefixes. | |||
| 62 | + | func withBranchPrefixes(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 63 | + | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"token.branch_prefixes"}}, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestTokenCreateBranchPrefixes(t *testing.T) { | |||
| 67 | + | echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |||
| 68 | + | return &ermite.CreateTokenResponse{ | |||
| 69 | + | Token: &ermite.Token{ | |||
| 70 | + | Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(), | |||
| 71 | + | CreatedAt: ts("2026-10-10T12:00:00Z"), | |||
| 72 | + | }, | |||
| 73 | + | Secret: "ermite_S", | |||
| 74 | + | }, nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) { | |||
| 78 | + | f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes} | |||
| 79 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha", | |||
| 80 | + | "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t) | |||
| 81 | + | assert.Equal(t, []string{"agent/claude/", "bot/"}, lastCreateToken(t, f).GetBranchPrefixes()) | |||
| 82 | + | assert.Equal(t, "ermite_S\n", res.stdout) | |||
| 83 | + | assert.Contains(t, res.stderr, "branches") | |||
| 84 | + | assert.Contains(t, res.stderr, "agent/claude/, bot/") | |||
| 85 | + | }) | |||
| 86 | + | ||||
| 87 | + | t.Run("without the flag the token shows every branch", func(t *testing.T) { | |||
| 88 | + | f := &fakeAPI{createToken: echo} | |||
| 89 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |||
| 90 | + | _, reqs := f.snapshot() | |||
| 91 | + | assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes()) | |||
| 92 | + | assert.Regexp(t, `branches\s+all`, res.stderr) | |||
| 93 | + | }) | |||
| 94 | + | ||||
| 95 | + | t.Run("json", func(t *testing.T) { | |||
| 96 | + | f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes} | |||
| 97 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t) | |||
| 98 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | |||
| 99 | + | }) | |||
| 100 | + | ||||
| 101 | + | t.Run("a server without the feature is asked nothing", func(t *testing.T) { | |||
| 102 | + | for _, tt := range []struct { | |||
| 103 | + | name string | |||
| 104 | + | version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) | |||
| 105 | + | wantMsg string | |||
| 106 | + | }{ | |||
| 107 | + | {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 108 | + | return &ermite.GetVersionResponse{Version: "v0.3.0", Features: []string{"repo.update", "session"}}, nil | |||
| 109 | + | }, "does not support token.branch_prefixes"}, | |||
| 110 | + | {"version unreadable", nil, "does not implement this call"}, // the fake answers unimplemented | |||
| 111 | + | {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 112 | + | return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused")) | |||
| 113 | + | }, "cannot reach the server"}, | |||
| 114 | + | } { | |||
| 115 | + | f := &fakeAPI{createToken: echo, getVersion: tt.version} | |||
| 116 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |||
| 117 | + | require.Error(t, res.err, tt.name) | |||
| 118 | + | assert.Contains(t, strings.ToLower(res.err.Error()), tt.wantMsg, tt.name) | |||
| 119 | + | calls, _ := f.snapshot() | |||
| 120 | + | assert.NotContains(t, calls, "CreateToken", "%s: no token is created", tt.name) | |||
| 121 | + | assert.Empty(t, res.stdout, tt.name) | |||
| 122 | + | } | |||
| 123 | + | }) | |||
| 124 | + | ||||
| 125 | + | t.Run("without --branch-prefix the features are not read", func(t *testing.T) { | |||
| 126 | + | f := &fakeAPI{createToken: echo} // no getVersion: it would answer unimplemented | |||
| 127 | + | runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t) | |||
| 128 | + | calls, _ := f.snapshot() | |||
| 129 | + | assert.NotContains(t, calls, "GetVersion") | |||
| 130 | + | }) | |||
| 131 | + | ||||
| 132 | + | t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) { | |||
| 133 | + | f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |||
| 134 | + | return &ermite.CreateTokenResponse{ | |||
| 135 | + | Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")}, | |||
| 136 | + | Secret: "ermite_UNLIMITED", | |||
| 137 | + | }, nil | |||
| 138 | + | }, getVersion: withBranchPrefixes} | |||
| 139 | + | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/") | |||
| 140 | + | require.Error(t, res.err) | |||
| 141 | + | assert.Contains(t, res.err.Error(), "without the requested branch prefixes") | |||
| 142 | + | assert.Contains(t, res.err.Error(), "ermite token revoke tok9") | |||
| 143 | + | assert.NotContains(t, res.stdout, "UNLIMITED") | |||
| 144 | + | assert.NotContains(t, res.stderr, "UNLIMITED") | |||
| 145 | + | assert.NotContains(t, res.err.Error(), "UNLIMITED") | |||
| 146 | + | }) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestTokenListBranchPrefixes(t *testing.T) { | |||
| 150 | + | f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) { | |||
| 151 | + | return &ermite.ListTokensResponse{Tokens: []*ermite.Token{ | |||
| 152 | + | {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")}, | |||
| 153 | + | {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")}, | |||
| 154 | + | }}, nil | |||
| 155 | + | }} | |||
| 156 | + | url := f.serve(t) | |||
| 157 | + | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | |||
| 158 | + | assert.Equal(t, ""+ | |||
| 159 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ | |||
| 160 | + | "t2 claude repo:write all agent/claude/,bot/ never never\n"+ | |||
| 161 | + | "t1 ci repo:read all all never never\n", res.stdout) | |||
| 162 | + | ||||
| 163 | + | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | |||
| 164 | + | assert.JSONEq(t, `[ | |||
| 165 | + | {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null}, | |||
| 166 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | |||
| 167 | + | ]`, res.stdout) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 45 | func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | 170 | func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | ||
| 46 | for preset, want := range map[string][]ermite.Scope{ | 171 | for preset, want := range map[string][]ermite.Scope{ | ||
| 47 | "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, | 172 | "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, | ||
| @@ -70,7 +195,7 @@ func TestTokenCreateJSON(t *testing.T) { | |||||
| 70 | }, nil | 195 | }, nil | ||
| 71 | }} | 196 | }} | ||
| 72 | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t) | 197 | res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t) | ||
| 73 | - | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) | 198 | + | assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout) |
| 74 | assert.Empty(t, res.stderr) | 199 | assert.Empty(t, res.stderr) | ||
| 75 | } | 200 | } | ||
| 76 | 201 | ||||
| @@ -106,14 +231,14 @@ func TestTokenList(t *testing.T) { | |||||
| 106 | url := f.serve(t) | 231 | url := f.serve(t) | ||
| 107 | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | 232 | res := runCLI(t, envFor(url), "", "token", "list").ok(t) | ||
| 108 | assert.Equal(t, ""+ | 233 | assert.Equal(t, ""+ | ||
| 109 | - | "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+ | 234 | + | "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+ |
| 110 | - | "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+ | 235 | + | "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+ |
| 111 | - | "t1 ci repo:read all never never\n", res.stdout) | 236 | + | "t1 ci repo:read all all never never\n", res.stdout) |
| 112 | 237 | ||||
| 113 | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | 238 | res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t) | ||
| 114 | assert.JSONEq(t, `[ | 239 | assert.JSONEq(t, `[ | ||
| 115 | - | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, | 240 | + | {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"}, |
| 116 | - | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} | 241 | + | {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null} |
| 117 | ]`, res.stdout) | 242 | ]`, res.stdout) | ||
| 118 | assert.NotContains(t, res.stdout, "secret") | 243 | assert.NotContains(t, res.stdout, "secret") | ||
| 119 | } | 244 | } | ||
M internal/app/cli_version.go +7 -6
| @@ -109,10 +109,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 109 | 109 | ||
| 110 | 110 | // featureUse says what breaks without a feature, for the version warning. | |
| 111 | 111 | var featureUse = map[string]string{ | |
| 112 | - | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 113 | - | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 114 | - | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 115 | - | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 116 | - | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 117 | - | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 112 | + | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 113 | + | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 114 | + | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 115 | + | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | |
| 116 | + | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 117 | + | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 118 | + | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |
| 118 | 119 | } | |
| @@ -109,10 +109,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||||
| 109 | 109 | ||||
| 110 | // featureUse says what breaks without a feature, for the version warning. | 110 | // featureUse says what breaks without a feature, for the version warning. | ||
| 111 | var featureUse = map[string]string{ | 111 | var featureUse = map[string]string{ | ||
| 112 | - | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | 112 | + | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", |
| 113 | - | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | 113 | + | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", |
| 114 | - | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | 114 | + | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", |
| 115 | - | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", | 115 | + | client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept", |
| 116 | - | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | 116 | + | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", |
| 117 | - | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | 117 | + | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", |
| 118 | + | client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits", | |||
| 118 | } | 119 | } | ||
M internal/app/cli_version_test.go +5 -5
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // allFeatures are the features this client uses. | |
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"} | |
| 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners"} | |
| 18 | 18 | ||
| 19 | 19 | func versionFake(features ...string) *fakeAPI { | |
| 20 | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| @@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 35 | 35 | f := versionFake(allFeatures...) | |
| 36 | 36 | url := f.serve(t) | |
| 37 | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout) | |
| 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners\n", res.stdout) | |
| 39 | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 40 | 40 | ||
| 41 | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout) | |
| 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout) | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners") | |
| 46 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners") | |
| 47 | 47 | url := f.serve(t) | |
| 48 | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 49 | 49 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 51 | 51 | assert.NotContains(t, res.stderr, "repo.update") | |
| 52 | 52 | ||
| 53 | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 56 | 56 | ||
| 57 | 57 | f = versionFake() | |
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // allFeatures are the features this client uses. | 16 | // allFeatures are the features this client uses. | ||
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"} | 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners"} |
| 18 | 18 | ||||
| 19 | func versionFake(features ...string) *fakeAPI { | 19 | func versionFake(features ...string) *fakeAPI { | ||
| 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | ||
| @@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 35 | f := versionFake(allFeatures...) | 35 | f := versionFake(allFeatures...) | ||
| 36 | url := f.serve(t) | 36 | url := f.serve(t) | ||
| 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout) | 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners\n", res.stdout) |
| 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 40 | 40 | ||||
| 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout) | 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout) |
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 46 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners") | 46 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners") |
| 47 | url := f.serve(t) | 47 | url := f.serve(t) | ||
| 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 49 | assert.Contains(t, res.stdout, "v0.4.0") | 49 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 51 | assert.NotContains(t, res.stderr, "repo.update") | 51 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 52 | 52 | ||||
| 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout) | 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout) |
| 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 56 | 56 | ||||
| 57 | f = versionFake() | 57 | f = versionFake() | ||
M internal/app/cli_web.go +1 -1
| @@ -23,7 +23,7 @@ func newWebCmd(env *cliEnv) *cobra.Command { | |||
| 23 | 23 | Use: "web", | |
| 24 | 24 | Short: "Sign the owner in to the web interface of a remote server", | |
| 25 | 25 | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | |
| 26 | - | "token that holds every scope and is not limited to repositories.", | |
| 26 | + | "token that holds every scope and is limited neither to repositories nor to branches.", | |
| 27 | 27 | } | |
| 28 | 28 | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | |
| 29 | 29 | return web | |
| @@ -23,7 +23,7 @@ func newWebCmd(env *cliEnv) *cobra.Command { | |||||
| 23 | Use: "web", | 23 | Use: "web", | ||
| 24 | Short: "Sign the owner in to the web interface of a remote server", | 24 | Short: "Sign the owner in to the web interface of a remote server", | ||
| 25 | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | 25 | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | ||
| 26 | - | "token that holds every scope and is not limited to repositories.", | 26 | + | "token that holds every scope and is limited neither to repositories nor to branches.", |
| 27 | } | 27 | } | ||
| 28 | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | 28 | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | ||
| 29 | return web | 29 | return web | ||
A internal/app/e2e_branches_test.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 14 | + | ||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // branchToken creates a stored token limited to branch prefixes, as root. | |
| 21 | + | func (h *harness) branchToken(scopes []domain.Scope, prefixes []string, repos ...string) (secret, id string) { | |
| 22 | + | h.t.Helper() | |
| 23 | + | p := ports.CreateTokenParams{Label: "claude", Scopes: scopes, BranchPrefixes: prefixes} | |
| 24 | + | for _, r := range repos { | |
| 25 | + | p.Repos = append(p.Repos, h.name(r)) | |
| 26 | + | } | |
| 27 | + | got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p) | |
| 28 | + | require.NoError(h.t, err) | |
| 29 | + | return got.Secret, got.Token.ID | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // AC5: a token limited to branch prefixes, through real git pushes and the API. | |
| 33 | + | func TestAC5BranchLimitedToken(t *testing.T) { | |
| 34 | + | h := newHarness(t, harnessOpts{}) | |
| 35 | + | wt := h.workTree("main", "a.txt") | |
| 36 | + | out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main") | |
| 37 | + | require.NoError(t, err, out) | |
| 38 | + | mainID := h.bareGit("proj", "rev-parse", "refs/heads/main") | |
| 39 | + | ||
| 40 | + | secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite, domain.ScopeMRMerge), | |
| 41 | + | []string{"agent/claude/"}, "proj") | |
| 42 | + | remote := h.urlAuth(secret, "/proj.git") | |
| 43 | + | commit := func(file string) { | |
| 44 | + | require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644)) | |
| 45 | + | h.mustGit(wt, "add", file) | |
| 46 | + | h.mustGit(wt, "commit", "-q", "-m", file) | |
| 47 | + | } | |
| 48 | + | refs := func() string { return h.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") } | |
| 49 | + | ||
| 50 | + | // It pushes, force-pushes and deletes under its prefix. | |
| 51 | + | h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/x") | |
| 52 | + | commit("x1") | |
| 53 | + | out, err = h.git(wt, "push", "-q", remote, "agent/claude/x") | |
| 54 | + | require.NoError(t, err, out) | |
| 55 | + | assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x")) | |
| 56 | + | ||
| 57 | + | h.mustGit(wt, "reset", "-q", "--hard", "main") | |
| 58 | + | commit("x2") | |
| 59 | + | out, err = h.git(wt, "push", "-q", "--force", remote, "agent/claude/x") | |
| 60 | + | require.NoError(t, err, out) | |
| 61 | + | assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x")) | |
| 62 | + | ||
| 63 | + | h.mustGit(wt, "branch", "agent/claude/deep/y") | |
| 64 | + | out, err = h.git(wt, "push", "-q", remote, "agent/claude/deep/y") | |
| 65 | + | require.NoError(t, err, out) | |
| 66 | + | out, err = h.git(wt, "push", "-q", remote, ":refs/heads/agent/claude/deep/y") | |
| 67 | + | require.NoError(t, err, out) | |
| 68 | + | _, err = h.git(h.repoDir("proj"), "rev-parse", "--verify", "-q", "refs/heads/agent/claude/deep/y") | |
| 69 | + | require.Error(t, err, "deleted") | |
| 70 | + | ||
| 71 | + | // Every other ref refuses the push, with a message naming it. | |
| 72 | + | before := refs() | |
| 73 | + | for _, tt := range []struct{ name, refspec, ref string }{ | |
| 74 | + | {"main", "HEAD:refs/heads/main", "refs/heads/main"}, | |
| 75 | + | {"force-push to main", "+HEAD:refs/heads/main", "refs/heads/main"}, | |
| 76 | + | {"delete main", ":refs/heads/main", "refs/heads/main"}, | |
| 77 | + | {"a new branch outside", "HEAD:refs/heads/other", "refs/heads/other"}, | |
| 78 | + | {"the prefix without a name", "HEAD:refs/heads/agent/claude", "refs/heads/agent/claude"}, | |
| 79 | + | {"a sibling sharing the text", "HEAD:refs/heads/agent/claudex/y", "refs/heads/agent/claudex/y"}, | |
| 80 | + | {"a tag", "HEAD:refs/tags/v1", "refs/tags/v1"}, | |
| 81 | + | {"a tag named like the prefix", "HEAD:refs/tags/agent/claude/v1", "refs/tags/agent/claude/v1"}, | |
| 82 | + | {"a notes ref", "HEAD:refs/notes/commits", "refs/notes/commits"}, | |
| 83 | + | {"a custom ref", "HEAD:refs/agent/claude/z", "refs/agent/claude/z"}, | |
| 84 | + | } { | |
| 85 | + | out, err = h.git(wt, "push", remote, tt.refspec) | |
| 86 | + | require.Error(t, err, tt.name) | |
| 87 | + | assert.Contains(t, out, tt.ref+" is outside the branches this token may push (agent/claude/)", tt.name) | |
| 88 | + | assert.Equal(t, before, refs(), "%s: nothing moved", tt.name) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // A mixed push is refused as a whole: the allowed ref does not move either. | |
| 92 | + | commit("x3") | |
| 93 | + | out, err = h.git(wt, "push", "--atomic", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/heads/main") | |
| 94 | + | require.Error(t, err) | |
| 95 | + | assert.Contains(t, out, "refs/heads/main is outside") | |
| 96 | + | assert.Equal(t, before, refs()) | |
| 97 | + | out, err = h.git(wt, "push", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/tags/v2") | |
| 98 | + | require.Error(t, err) | |
| 99 | + | assert.Contains(t, out, "refs/tags/v2 is outside") | |
| 100 | + | assert.Equal(t, before, refs(), "git receives both updates in one pack: pre-receive refuses both") | |
| 101 | + | assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |
| 102 | + | ||
| 103 | + | // It cannot create a repository: not by push-to-create, not by the API. | |
| 104 | + | h.mustGit(wt, "checkout", "-q", "agent/claude/x") | |
| 105 | + | out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/brand-new.git"), "agent/claude/x") | |
| 106 | + | require.Error(t, err, out) | |
| 107 | + | assert.NoDirExists(t, h.repoDir("brand-new")) | |
| 108 | + | anyRepo, _ := h.branchToken(scopes(domain.ScopeRepoWrite), []string{"agent/claude/"}) // not limited to a repository | |
| 109 | + | out, err = h.git(wt, "push", "-q", h.urlAuth(anyRepo, "/brand-new.git"), "agent/claude/x") | |
| 110 | + | require.Error(t, err, out) | |
| 111 | + | assert.NoDirExists(t, h.repoDir("brand-new")) | |
| 112 | + | assert.Equal(t, 404, h.statusAuth("GET", "/brand-new.git/info/refs?service=git-receive-pack", anyRepo)) | |
| 113 | + | _, err = h.api(anyRepo).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"})) | |
| 114 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 115 | + | assert.NoDirExists(t, h.repoDir("via-api")) | |
| 116 | + | // The same token without prefixes does both. | |
| 117 | + | free := h.token(scopes(domain.ScopeRepoWrite)) | |
| 118 | + | _, err = h.api(free).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"})) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | ||
| 121 | + | // It cannot delete a repository, even with repo:admin. | |
| 122 | + | admin, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/claude/"}, "via-api") | |
| 123 | + | _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"})) | |
| 124 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 125 | + | assert.DirExists(t, h.repoDir("via-api")) | |
| 126 | + | // ... nor change its settings (default branch, visibility, ...). | |
| 127 | + | _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 128 | + | Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |
| 129 | + | })) | |
| 130 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 131 | + | got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"})) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility()) | |
| 134 | + | ||
| 135 | + | // It never has owner rights, whatever its scopes. | |
| 136 | + | all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"}) | |
| 137 | + | _, err = h.api(all).session.SignOutEverywhere(h.ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 138 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 139 | + | _, err = h.api(all).session.CreateLoginLink(h.ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 140 | + | assert.Equal(t, "permission_denied", codeName(err)) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // AC5: merge requests write branches: a limited token merges only into its prefixes. | |
| 144 | + | func TestAC5BranchLimitedMerge(t *testing.T) { | |
| 145 | + | h := newHarness(t, harnessOpts{}) | |
| 146 | + | wt := h.workTree("main", "a.txt") | |
| 147 | + | out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main") | |
| 148 | + | require.NoError(t, err, out) | |
| 149 | + | h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/feat") | |
| 150 | + | h.writeFile(wt, "f.txt", []byte("f\n")) | |
| 151 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "agent/claude/feat") | |
| 152 | + | feat := h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat") | |
| 153 | + | mainID := h.bareGit("proj", "rev-parse", "refs/heads/main") | |
| 154 | + | ||
| 155 | + | secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge), []string{"agent/claude/"}, "proj") | |
| 156 | + | c := h.api(secret) | |
| 157 | + | created, err := c.mr.CreateMergeRequest(h.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | |
| 158 | + | Repo: "proj", Title: "feat", SourceBranch: "agent/claude/feat", | |
| 159 | + | })) | |
| 160 | + | require.NoError(t, err, "opening a request writes no ref") | |
| 161 | + | n := created.Msg.GetMergeRequest().GetNumber() | |
| 162 | + | ||
| 163 | + | for _, del := range []bool{false, true} { | |
| 164 | + | _, err = c.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{ | |
| 165 | + | Repo: "proj", Number: n, HeadId: feat, DeleteSourceBranch: del, | |
| 166 | + | })) | |
| 167 | + | assert.Equal(t, "permission_denied", codeName(err), "into main, delete=%v", del) | |
| 168 | + | } | |
| 169 | + | assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |
| 170 | + | assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat")) | |
| 171 | + | ||
| 172 | + | // The merge goes through for a token without prefixes. | |
| 173 | + | m := h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))) | |
| 174 | + | _, err = m.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: feat})) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // AC5: a limited token only creates tokens within its prefixes, over the API. | |
| 180 | + | func TestAC5BranchLimitedTokenCreation(t *testing.T) { | |
| 181 | + | h := newHarness(t, harnessOpts{}) | |
| 182 | + | secret, _ := h.branchToken(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), []string{"agent/claude/"}) | |
| 183 | + | c := h.api(secret) | |
| 184 | + | create := func(prefixes ...string) (*connect.Response[pb.CreateTokenResponse], error) { | |
| 185 | + | return c.token.CreateToken(h.ctx, connect.NewRequest(&pb.CreateTokenRequest{ | |
| 186 | + | Label: "child", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: prefixes, | |
| 187 | + | })) | |
| 188 | + | } | |
| 189 | + | _, err := create() | |
| 190 | + | assert.Equal(t, "permission_denied", codeName(err), "no prefixes") | |
| 191 | + | _, err = create("agent/") | |
| 192 | + | assert.Equal(t, "permission_denied", codeName(err), "wider") | |
| 193 | + | _, err = create("agent/claudex/") | |
| 194 | + | assert.Equal(t, "permission_denied", codeName(err), "sibling") | |
| 195 | + | _, err = create("agent") | |
| 196 | + | assert.Equal(t, "invalid_argument", codeName(err), "malformed") | |
| 197 | + | res, err := create("agent/claude/sub/") | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | assert.Equal(t, []string{"agent/claude/sub/"}, res.Msg.GetToken().GetBranchPrefixes()) | |
| 200 | + | ||
| 201 | + | // The listing shows the prefixes. | |
| 202 | + | list, err := h.api(adminToken).token.ListTokens(h.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | var seen int | |
| 205 | + | for _, tk := range list.Msg.GetTokens() { | |
| 206 | + | if tk.GetLabel() == "child" { | |
| 207 | + | assert.Equal(t, []string{"agent/claude/sub/"}, tk.GetBranchPrefixes()) | |
| 208 | + | seen++ | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | assert.Equal(t, 1, seen) | |
| 212 | + | } | |
| @@ -0,0 +1,212 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |||
| 14 | + | ||||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // branchToken creates a stored token limited to branch prefixes, as root. | |||
| 21 | + | func (h *harness) branchToken(scopes []domain.Scope, prefixes []string, repos ...string) (secret, id string) { | |||
| 22 | + | h.t.Helper() | |||
| 23 | + | p := ports.CreateTokenParams{Label: "claude", Scopes: scopes, BranchPrefixes: prefixes} | |||
| 24 | + | for _, r := range repos { | |||
| 25 | + | p.Repos = append(p.Repos, h.name(r)) | |||
| 26 | + | } | |||
| 27 | + | got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p) | |||
| 28 | + | require.NoError(h.t, err) | |||
| 29 | + | return got.Secret, got.Token.ID | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // AC5: a token limited to branch prefixes, through real git pushes and the API. | |||
| 33 | + | func TestAC5BranchLimitedToken(t *testing.T) { | |||
| 34 | + | h := newHarness(t, harnessOpts{}) | |||
| 35 | + | wt := h.workTree("main", "a.txt") | |||
| 36 | + | out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main") | |||
| 37 | + | require.NoError(t, err, out) | |||
| 38 | + | mainID := h.bareGit("proj", "rev-parse", "refs/heads/main") | |||
| 39 | + | ||||
| 40 | + | secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite, domain.ScopeMRMerge), | |||
| 41 | + | []string{"agent/claude/"}, "proj") | |||
| 42 | + | remote := h.urlAuth(secret, "/proj.git") | |||
| 43 | + | commit := func(file string) { | |||
| 44 | + | require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644)) | |||
| 45 | + | h.mustGit(wt, "add", file) | |||
| 46 | + | h.mustGit(wt, "commit", "-q", "-m", file) | |||
| 47 | + | } | |||
| 48 | + | refs := func() string { return h.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") } | |||
| 49 | + | ||||
| 50 | + | // It pushes, force-pushes and deletes under its prefix. | |||
| 51 | + | h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/x") | |||
| 52 | + | commit("x1") | |||
| 53 | + | out, err = h.git(wt, "push", "-q", remote, "agent/claude/x") | |||
| 54 | + | require.NoError(t, err, out) | |||
| 55 | + | assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x")) | |||
| 56 | + | ||||
| 57 | + | h.mustGit(wt, "reset", "-q", "--hard", "main") | |||
| 58 | + | commit("x2") | |||
| 59 | + | out, err = h.git(wt, "push", "-q", "--force", remote, "agent/claude/x") | |||
| 60 | + | require.NoError(t, err, out) | |||
| 61 | + | assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x")) | |||
| 62 | + | ||||
| 63 | + | h.mustGit(wt, "branch", "agent/claude/deep/y") | |||
| 64 | + | out, err = h.git(wt, "push", "-q", remote, "agent/claude/deep/y") | |||
| 65 | + | require.NoError(t, err, out) | |||
| 66 | + | out, err = h.git(wt, "push", "-q", remote, ":refs/heads/agent/claude/deep/y") | |||
| 67 | + | require.NoError(t, err, out) | |||
| 68 | + | _, err = h.git(h.repoDir("proj"), "rev-parse", "--verify", "-q", "refs/heads/agent/claude/deep/y") | |||
| 69 | + | require.Error(t, err, "deleted") | |||
| 70 | + | ||||
| 71 | + | // Every other ref refuses the push, with a message naming it. | |||
| 72 | + | before := refs() | |||
| 73 | + | for _, tt := range []struct{ name, refspec, ref string }{ | |||
| 74 | + | {"main", "HEAD:refs/heads/main", "refs/heads/main"}, | |||
| 75 | + | {"force-push to main", "+HEAD:refs/heads/main", "refs/heads/main"}, | |||
| 76 | + | {"delete main", ":refs/heads/main", "refs/heads/main"}, | |||
| 77 | + | {"a new branch outside", "HEAD:refs/heads/other", "refs/heads/other"}, | |||
| 78 | + | {"the prefix without a name", "HEAD:refs/heads/agent/claude", "refs/heads/agent/claude"}, | |||
| 79 | + | {"a sibling sharing the text", "HEAD:refs/heads/agent/claudex/y", "refs/heads/agent/claudex/y"}, | |||
| 80 | + | {"a tag", "HEAD:refs/tags/v1", "refs/tags/v1"}, | |||
| 81 | + | {"a tag named like the prefix", "HEAD:refs/tags/agent/claude/v1", "refs/tags/agent/claude/v1"}, | |||
| 82 | + | {"a notes ref", "HEAD:refs/notes/commits", "refs/notes/commits"}, | |||
| 83 | + | {"a custom ref", "HEAD:refs/agent/claude/z", "refs/agent/claude/z"}, | |||
| 84 | + | } { | |||
| 85 | + | out, err = h.git(wt, "push", remote, tt.refspec) | |||
| 86 | + | require.Error(t, err, tt.name) | |||
| 87 | + | assert.Contains(t, out, tt.ref+" is outside the branches this token may push (agent/claude/)", tt.name) | |||
| 88 | + | assert.Equal(t, before, refs(), "%s: nothing moved", tt.name) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // A mixed push is refused as a whole: the allowed ref does not move either. | |||
| 92 | + | commit("x3") | |||
| 93 | + | out, err = h.git(wt, "push", "--atomic", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/heads/main") | |||
| 94 | + | require.Error(t, err) | |||
| 95 | + | assert.Contains(t, out, "refs/heads/main is outside") | |||
| 96 | + | assert.Equal(t, before, refs()) | |||
| 97 | + | out, err = h.git(wt, "push", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/tags/v2") | |||
| 98 | + | require.Error(t, err) | |||
| 99 | + | assert.Contains(t, out, "refs/tags/v2 is outside") | |||
| 100 | + | assert.Equal(t, before, refs(), "git receives both updates in one pack: pre-receive refuses both") | |||
| 101 | + | assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |||
| 102 | + | ||||
| 103 | + | // It cannot create a repository: not by push-to-create, not by the API. | |||
| 104 | + | h.mustGit(wt, "checkout", "-q", "agent/claude/x") | |||
| 105 | + | out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/brand-new.git"), "agent/claude/x") | |||
| 106 | + | require.Error(t, err, out) | |||
| 107 | + | assert.NoDirExists(t, h.repoDir("brand-new")) | |||
| 108 | + | anyRepo, _ := h.branchToken(scopes(domain.ScopeRepoWrite), []string{"agent/claude/"}) // not limited to a repository | |||
| 109 | + | out, err = h.git(wt, "push", "-q", h.urlAuth(anyRepo, "/brand-new.git"), "agent/claude/x") | |||
| 110 | + | require.Error(t, err, out) | |||
| 111 | + | assert.NoDirExists(t, h.repoDir("brand-new")) | |||
| 112 | + | assert.Equal(t, 404, h.statusAuth("GET", "/brand-new.git/info/refs?service=git-receive-pack", anyRepo)) | |||
| 113 | + | _, err = h.api(anyRepo).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"})) | |||
| 114 | + | assert.Equal(t, "permission_denied", codeName(err)) | |||
| 115 | + | assert.NoDirExists(t, h.repoDir("via-api")) | |||
| 116 | + | // The same token without prefixes does both. | |||
| 117 | + | free := h.token(scopes(domain.ScopeRepoWrite)) | |||
| 118 | + | _, err = h.api(free).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"})) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | ||||
| 121 | + | // It cannot delete a repository, even with repo:admin. | |||
| 122 | + | admin, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/claude/"}, "via-api") | |||
| 123 | + | _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"})) | |||
| 124 | + | assert.Equal(t, "permission_denied", codeName(err)) | |||
| 125 | + | assert.DirExists(t, h.repoDir("via-api")) | |||
| 126 | + | // ... nor change its settings (default branch, visibility, ...). | |||
| 127 | + | _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 128 | + | Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}, | |||
| 129 | + | })) | |||
| 130 | + | assert.Equal(t, "permission_denied", codeName(err)) | |||
| 131 | + | got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"})) | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility()) | |||
| 134 | + | ||||
| 135 | + | // It never has owner rights, whatever its scopes. | |||
| 136 | + | all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"}) | |||
| 137 | + | _, err = h.api(all).session.SignOutEverywhere(h.ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |||
| 138 | + | assert.Equal(t, "permission_denied", codeName(err)) | |||
| 139 | + | _, err = h.api(all).session.CreateLoginLink(h.ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |||
| 140 | + | assert.Equal(t, "permission_denied", codeName(err)) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // AC5: merge requests write branches: a limited token merges only into its prefixes. | |||
| 144 | + | func TestAC5BranchLimitedMerge(t *testing.T) { | |||
| 145 | + | h := newHarness(t, harnessOpts{}) | |||
| 146 | + | wt := h.workTree("main", "a.txt") | |||
| 147 | + | out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main") | |||
| 148 | + | require.NoError(t, err, out) | |||
| 149 | + | h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/feat") | |||
| 150 | + | h.writeFile(wt, "f.txt", []byte("f\n")) | |||
| 151 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "agent/claude/feat") | |||
| 152 | + | feat := h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat") | |||
| 153 | + | mainID := h.bareGit("proj", "rev-parse", "refs/heads/main") | |||
| 154 | + | ||||
| 155 | + | secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge), []string{"agent/claude/"}, "proj") | |||
| 156 | + | c := h.api(secret) | |||
| 157 | + | created, err := c.mr.CreateMergeRequest(h.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | |||
| 158 | + | Repo: "proj", Title: "feat", SourceBranch: "agent/claude/feat", | |||
| 159 | + | })) | |||
| 160 | + | require.NoError(t, err, "opening a request writes no ref") | |||
| 161 | + | n := created.Msg.GetMergeRequest().GetNumber() | |||
| 162 | + | ||||
| 163 | + | for _, del := range []bool{false, true} { | |||
| 164 | + | _, err = c.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{ | |||
| 165 | + | Repo: "proj", Number: n, HeadId: feat, DeleteSourceBranch: del, | |||
| 166 | + | })) | |||
| 167 | + | assert.Equal(t, "permission_denied", codeName(err), "into main, delete=%v", del) | |||
| 168 | + | } | |||
| 169 | + | assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |||
| 170 | + | assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat")) | |||
| 171 | + | ||||
| 172 | + | // The merge goes through for a token without prefixes. | |||
| 173 | + | m := h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))) | |||
| 174 | + | _, err = m.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: feat})) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/main")) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // AC5: a limited token only creates tokens within its prefixes, over the API. | |||
| 180 | + | func TestAC5BranchLimitedTokenCreation(t *testing.T) { | |||
| 181 | + | h := newHarness(t, harnessOpts{}) | |||
| 182 | + | secret, _ := h.branchToken(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), []string{"agent/claude/"}) | |||
| 183 | + | c := h.api(secret) | |||
| 184 | + | create := func(prefixes ...string) (*connect.Response[pb.CreateTokenResponse], error) { | |||
| 185 | + | return c.token.CreateToken(h.ctx, connect.NewRequest(&pb.CreateTokenRequest{ | |||
| 186 | + | Label: "child", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: prefixes, | |||
| 187 | + | })) | |||
| 188 | + | } | |||
| 189 | + | _, err := create() | |||
| 190 | + | assert.Equal(t, "permission_denied", codeName(err), "no prefixes") | |||
| 191 | + | _, err = create("agent/") | |||
| 192 | + | assert.Equal(t, "permission_denied", codeName(err), "wider") | |||
| 193 | + | _, err = create("agent/claudex/") | |||
| 194 | + | assert.Equal(t, "permission_denied", codeName(err), "sibling") | |||
| 195 | + | _, err = create("agent") | |||
| 196 | + | assert.Equal(t, "invalid_argument", codeName(err), "malformed") | |||
| 197 | + | res, err := create("agent/claude/sub/") | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | assert.Equal(t, []string{"agent/claude/sub/"}, res.Msg.GetToken().GetBranchPrefixes()) | |||
| 200 | + | ||||
| 201 | + | // The listing shows the prefixes. | |||
| 202 | + | list, err := h.api(adminToken).token.ListTokens(h.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |||
| 203 | + | require.NoError(t, err) | |||
| 204 | + | var seen int | |||
| 205 | + | for _, tk := range list.Msg.GetTokens() { | |||
| 206 | + | if tk.GetLabel() == "child" { | |||
| 207 | + | assert.Equal(t, []string{"agent/claude/sub/"}, tk.GetBranchPrefixes()) | |||
| 208 | + | seen++ | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | assert.Equal(t, 1, seen) | |||
| 212 | + | } | |||
M internal/domain/principal.go +67 -15
| @@ -4,6 +4,7 @@ package domain | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "slices" | |
| 7 | + | "strings" | |
| 7 | 8 | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -22,12 +23,14 @@ const ( | |||
| 22 | 23 | // token, the owner signed in on the web, or a stored token with its scopes | |
| 23 | 24 | // and repositories. | |
| 24 | 25 | type Principal struct { | |
| 25 | - | kind principalKind | |
| 26 | - | tokenID string | |
| 27 | - | scopes []Scope | |
| 28 | - | repos []RepoName | |
| 29 | - | expiresAt time.Time | |
| 30 | - | label string | |
| 26 | + | kind principalKind | |
| 27 | + | tokenID string | |
| 28 | + | scopes []Scope | |
| 29 | + | repos []RepoName | |
| 30 | + | // branchPrefixes limits the branches a token may write; empty = all. | |
| 31 | + | branchPrefixes []string | |
| 32 | + | expiresAt time.Time | |
| 33 | + | label string | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // Anonymous returns the principal of a caller without credentials. | |
| @@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} } | |||
| 44 | 47 | // TokenPrincipal returns the principal of the holder of a stored token. | |
| 45 | 48 | func TokenPrincipal(t Token) Principal { | |
| 46 | 49 | return Principal{ | |
| 47 | - | kind: kindToken, | |
| 48 | - | tokenID: t.ID, | |
| 49 | - | scopes: slices.Clone(t.Scopes), | |
| 50 | - | repos: slices.Clone(t.Repos), | |
| 51 | - | expiresAt: t.ExpiresAt, | |
| 52 | - | label: t.Label, | |
| 50 | + | kind: kindToken, | |
| 51 | + | tokenID: t.ID, | |
| 52 | + | scopes: slices.Clone(t.Scopes), | |
| 53 | + | repos: slices.Clone(t.Repos), | |
| 54 | + | branchPrefixes: slices.Clone(t.BranchPrefixes), | |
| 55 | + | expiresAt: t.ExpiresAt, | |
| 56 | + | label: t.Label, | |
| 53 | 57 | } | |
| 54 | 58 | } | |
| 55 | 59 | ||
| @@ -72,13 +76,13 @@ func (p Principal) Label() string { | |||
| 72 | 76 | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | |
| 73 | 77 | ||
| 74 | 78 | // HasOwnerRights reports whether p is the owner: root, the owner's web | |
| 75 | - | // session, or a stored token that holds every scope and is not limited to a | |
| 76 | - | // list of repositories. It decides who may manage browser sessions. | |
| 79 | + | // session, or a stored token that holds every scope and is limited neither to | |
| 80 | + | // a list of repositories nor to branch prefixes. It decides who may manage browser sessions. | |
| 77 | 81 | func (p Principal) HasOwnerRights() bool { | |
| 78 | 82 | if p.IsRoot() { | |
| 79 | 83 | return true | |
| 80 | 84 | } | |
| 81 | - | if p.kind != kindToken || len(p.repos) > 0 { | |
| 85 | + | if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() { | |
| 82 | 86 | return false | |
| 83 | 87 | } | |
| 84 | 88 | for _, s := range AllScopes() { | |
| @@ -105,6 +109,54 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) } | |||
| 105 | 109 | // repository. | |
| 106 | 110 | func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | |
| 107 | 111 | ||
| 112 | + | // BranchPrefixes returns the branch prefixes of a stored token; empty means | |
| 113 | + | // every branch. | |
| 114 | + | func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) } | |
| 115 | + | ||
| 116 | + | // IsBranchLimited reports whether p is limited to branch prefixes. A limited | |
| 117 | + | // principal has no owner rights, creates no repository and administers none. | |
| 118 | + | func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 } | |
| 119 | + | ||
| 120 | + | // CoversBranchPrefix reports whether s lies under one of the branch prefixes of | |
| 121 | + | // p: s starts with one of them. An empty prefix, which ParseBranchPrefix never | |
| 122 | + | // lets in, covers nothing instead of everything, so that a corrupt record | |
| 123 | + | // limits a token and never widens it. It is false without prefixes: callers | |
| 124 | + | // test IsBranchLimited first. | |
| 125 | + | func (p Principal) CoversBranchPrefix(s string) bool { | |
| 126 | + | for _, prefix := range p.branchPrefixes { | |
| 127 | + | if prefix != "" && strings.HasPrefix(s, prefix) { | |
| 128 | + | return true | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // AllowsRef reports whether p may write the ref (create, update or delete | |
| 135 | + | // it). Without branch prefixes every ref is allowed. With them, only a branch | |
| 136 | + | // "refs/heads/<name>" under one of the prefixes is: tags, every other ref and | |
| 137 | + | // a branch outside the prefixes are not. | |
| 138 | + | // | |
| 139 | + | // It judges the name of the ref only, and rests on two assumptions about the | |
| 140 | + | // repositories ermite serves. First, no symbolic ref lives under refs/heads | |
| 141 | + | // (HEAD is the only symref): ermite never creates one and receive-pack cannot, | |
| 142 | + | // so a name under a prefix never points at a branch outside it. Second, ref | |
| 143 | + | // names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x" | |
| 144 | + | // is not "refs/heads/agent/x". On a case-insensitive file system the two could | |
| 145 | + | // name the same ref and the prefixes would not hold. | |
| 146 | + | func (p Principal) AllowsRef(ref string) bool { | |
| 147 | + | if !p.IsBranchLimited() { | |
| 148 | + | return true | |
| 149 | + | } | |
| 150 | + | name, ok := strings.CutPrefix(ref, "refs/heads/") | |
| 151 | + | if !ok { | |
| 152 | + | return false | |
| 153 | + | } | |
| 154 | + | if _, err := ParseBranchName(name); err != nil { | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | return p.CoversBranchPrefix(name) | |
| 158 | + | } | |
| 159 | + | ||
| 108 | 160 | // ExpiresAt returns the expiry of a stored token; zero means none. | |
| 109 | 161 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | |
| 110 | 162 | ||
| @@ -4,6 +4,7 @@ package domain | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "slices" | 6 | "slices" | ||
| 7 | + | "strings" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -22,12 +23,14 @@ const ( | |||||
| 22 | // token, the owner signed in on the web, or a stored token with its scopes | 23 | // token, the owner signed in on the web, or a stored token with its scopes | ||
| 23 | // and repositories. | 24 | // and repositories. | ||
| 24 | type Principal struct { | 25 | type Principal struct { | ||
| 25 | - | kind principalKind | 26 | + | kind principalKind |
| 26 | - | tokenID string | 27 | + | tokenID string |
| 27 | - | scopes []Scope | 28 | + | scopes []Scope |
| 28 | - | repos []RepoName | 29 | + | repos []RepoName |
| 29 | - | expiresAt time.Time | 30 | + | // branchPrefixes limits the branches a token may write; empty = all. |
| 30 | - | label string | 31 | + | branchPrefixes []string |
| 32 | + | expiresAt time.Time | |||
| 33 | + | label string | |||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 33 | // Anonymous returns the principal of a caller without credentials. | 36 | // Anonymous returns the principal of a caller without credentials. | ||
| @@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} } | |||||
| 44 | // TokenPrincipal returns the principal of the holder of a stored token. | 47 | // TokenPrincipal returns the principal of the holder of a stored token. | ||
| 45 | func TokenPrincipal(t Token) Principal { | 48 | func TokenPrincipal(t Token) Principal { | ||
| 46 | return Principal{ | 49 | return Principal{ | ||
| 47 | - | kind: kindToken, | 50 | + | kind: kindToken, |
| 48 | - | tokenID: t.ID, | 51 | + | tokenID: t.ID, |
| 49 | - | scopes: slices.Clone(t.Scopes), | 52 | + | scopes: slices.Clone(t.Scopes), |
| 50 | - | repos: slices.Clone(t.Repos), | 53 | + | repos: slices.Clone(t.Repos), |
| 51 | - | expiresAt: t.ExpiresAt, | 54 | + | branchPrefixes: slices.Clone(t.BranchPrefixes), |
| 52 | - | label: t.Label, | 55 | + | expiresAt: t.ExpiresAt, |
| 56 | + | label: t.Label, | |||
| 53 | } | 57 | } | ||
| 54 | } | 58 | } | ||
| 55 | 59 | ||||
| @@ -72,13 +76,13 @@ func (p Principal) Label() string { | |||||
| 72 | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | 76 | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | ||
| 73 | 77 | ||||
| 74 | // HasOwnerRights reports whether p is the owner: root, the owner's web | 78 | // HasOwnerRights reports whether p is the owner: root, the owner's web | ||
| 75 | - | // session, or a stored token that holds every scope and is not limited to a | 79 | + | // session, or a stored token that holds every scope and is limited neither to |
| 76 | - | // list of repositories. It decides who may manage browser sessions. | 80 | + | // a list of repositories nor to branch prefixes. It decides who may manage browser sessions. |
| 77 | func (p Principal) HasOwnerRights() bool { | 81 | func (p Principal) HasOwnerRights() bool { | ||
| 78 | if p.IsRoot() { | 82 | if p.IsRoot() { | ||
| 79 | return true | 83 | return true | ||
| 80 | } | 84 | } | ||
| 81 | - | if p.kind != kindToken || len(p.repos) > 0 { | 85 | + | if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() { |
| 82 | return false | 86 | return false | ||
| 83 | } | 87 | } | ||
| 84 | for _, s := range AllScopes() { | 88 | for _, s := range AllScopes() { | ||
| @@ -105,6 +109,54 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) } | |||||
| 105 | // repository. | 109 | // repository. | ||
| 106 | func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | 110 | func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | ||
| 107 | 111 | ||||
| 112 | + | // BranchPrefixes returns the branch prefixes of a stored token; empty means | |||
| 113 | + | // every branch. | |||
| 114 | + | func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) } | |||
| 115 | + | ||||
| 116 | + | // IsBranchLimited reports whether p is limited to branch prefixes. A limited | |||
| 117 | + | // principal has no owner rights, creates no repository and administers none. | |||
| 118 | + | func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 } | |||
| 119 | + | ||||
| 120 | + | // CoversBranchPrefix reports whether s lies under one of the branch prefixes of | |||
| 121 | + | // p: s starts with one of them. An empty prefix, which ParseBranchPrefix never | |||
| 122 | + | // lets in, covers nothing instead of everything, so that a corrupt record | |||
| 123 | + | // limits a token and never widens it. It is false without prefixes: callers | |||
| 124 | + | // test IsBranchLimited first. | |||
| 125 | + | func (p Principal) CoversBranchPrefix(s string) bool { | |||
| 126 | + | for _, prefix := range p.branchPrefixes { | |||
| 127 | + | if prefix != "" && strings.HasPrefix(s, prefix) { | |||
| 128 | + | return true | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // AllowsRef reports whether p may write the ref (create, update or delete | |||
| 135 | + | // it). Without branch prefixes every ref is allowed. With them, only a branch | |||
| 136 | + | // "refs/heads/<name>" under one of the prefixes is: tags, every other ref and | |||
| 137 | + | // a branch outside the prefixes are not. | |||
| 138 | + | // | |||
| 139 | + | // It judges the name of the ref only, and rests on two assumptions about the | |||
| 140 | + | // repositories ermite serves. First, no symbolic ref lives under refs/heads | |||
| 141 | + | // (HEAD is the only symref): ermite never creates one and receive-pack cannot, | |||
| 142 | + | // so a name under a prefix never points at a branch outside it. Second, ref | |||
| 143 | + | // names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x" | |||
| 144 | + | // is not "refs/heads/agent/x". On a case-insensitive file system the two could | |||
| 145 | + | // name the same ref and the prefixes would not hold. | |||
| 146 | + | func (p Principal) AllowsRef(ref string) bool { | |||
| 147 | + | if !p.IsBranchLimited() { | |||
| 148 | + | return true | |||
| 149 | + | } | |||
| 150 | + | name, ok := strings.CutPrefix(ref, "refs/heads/") | |||
| 151 | + | if !ok { | |||
| 152 | + | return false | |||
| 153 | + | } | |||
| 154 | + | if _, err := ParseBranchName(name); err != nil { | |||
| 155 | + | return false | |||
| 156 | + | } | |||
| 157 | + | return p.CoversBranchPrefix(name) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 108 | // ExpiresAt returns the expiry of a stored token; zero means none. | 160 | // ExpiresAt returns the expiry of a stored token; zero means none. | ||
| 109 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | 161 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | ||
| 110 | 162 | ||||
M internal/domain/token.go +49 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "fmt" | |
| 9 | 9 | "slices" | |
| 10 | + | "strings" | |
| 10 | 11 | "time" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -80,6 +81,10 @@ type Token struct { | |||
| 80 | 81 | Scopes []Scope | |
| 81 | 82 | // Repos limits the token to these repositories; empty means every one. | |
| 82 | 83 | Repos []RepoName | |
| 84 | + | // BranchPrefixes limits the branches the token may write to those under | |
| 85 | + | // one of these prefixes (each ends with "/"); empty means every branch. | |
| 86 | + | // A limited token cannot write tags or other refs, nor create repositories. | |
| 87 | + | BranchPrefixes []string | |
| 83 | 88 | // ExpiresAt is the zero time for a token that never expires. | |
| 84 | 89 | ExpiresAt time.Time | |
| 85 | 90 | CreatedAt time.Time | |
| @@ -90,3 +95,47 @@ type Token struct { | |||
| 90 | 95 | func (t Token) Expired(now time.Time) bool { | |
| 91 | 96 | return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt) | |
| 92 | 97 | } | |
| 98 | + | ||
| 99 | + | const ( | |
| 100 | + | // MaxBranchPrefixLen is the longest branch prefix, in bytes. | |
| 101 | + | MaxBranchPrefixLen = 100 | |
| 102 | + | // MaxBranchPrefixes is the most branch prefixes of a token. | |
| 103 | + | MaxBranchPrefixes = 8 | |
| 104 | + | ) | |
| 105 | + | ||
| 106 | + | // ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends | |
| 107 | + | // with "/", the rest is a valid branch name, and it has at most | |
| 108 | + | // MaxBranchPrefixLen bytes. Errors match ErrInvalidToken. | |
| 109 | + | func ParseBranchPrefix(s string) (string, error) { | |
| 110 | + | if len(s) > MaxBranchPrefixLen { | |
| 111 | + | return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen) | |
| 112 | + | } | |
| 113 | + | name, ok := strings.CutSuffix(s, "/") | |
| 114 | + | if !ok { | |
| 115 | + | return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s) | |
| 116 | + | } | |
| 117 | + | if _, err := ParseBranchName(name); err != nil { | |
| 118 | + | return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err) | |
| 119 | + | } | |
| 120 | + | return s, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // ParseBranchPrefixes validates a list of branch prefixes: at most | |
| 124 | + | // MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match | |
| 125 | + | // ErrInvalidToken. The result is nil for an empty list. | |
| 126 | + | func ParseBranchPrefixes(in []string) ([]string, error) { | |
| 127 | + | var out []string | |
| 128 | + | for _, s := range in { | |
| 129 | + | p, err := ParseBranchPrefix(s) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | if !slices.Contains(out, p) { | |
| 134 | + | out = append(out, p) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if len(out) > MaxBranchPrefixes { | |
| 138 | + | return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes) | |
| 139 | + | } | |
| 140 | + | return out, nil | |
| 141 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "encoding/hex" | 7 | "encoding/hex" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "slices" | 9 | "slices" | ||
| 10 | + | "strings" | |||
| 10 | "time" | 11 | "time" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -80,6 +81,10 @@ type Token struct { | |||||
| 80 | Scopes []Scope | 81 | Scopes []Scope | ||
| 81 | // Repos limits the token to these repositories; empty means every one. | 82 | // Repos limits the token to these repositories; empty means every one. | ||
| 82 | Repos []RepoName | 83 | Repos []RepoName | ||
| 84 | + | // BranchPrefixes limits the branches the token may write to those under | |||
| 85 | + | // one of these prefixes (each ends with "/"); empty means every branch. | |||
| 86 | + | // A limited token cannot write tags or other refs, nor create repositories. | |||
| 87 | + | BranchPrefixes []string | |||
| 83 | // ExpiresAt is the zero time for a token that never expires. | 88 | // ExpiresAt is the zero time for a token that never expires. | ||
| 84 | ExpiresAt time.Time | 89 | ExpiresAt time.Time | ||
| 85 | CreatedAt time.Time | 90 | CreatedAt time.Time | ||
| @@ -90,3 +95,47 @@ type Token struct { | |||||
| 90 | func (t Token) Expired(now time.Time) bool { | 95 | func (t Token) Expired(now time.Time) bool { | ||
| 91 | return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt) | 96 | return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt) | ||
| 92 | } | 97 | } | ||
| 98 | + | ||||
| 99 | + | const ( | |||
| 100 | + | // MaxBranchPrefixLen is the longest branch prefix, in bytes. | |||
| 101 | + | MaxBranchPrefixLen = 100 | |||
| 102 | + | // MaxBranchPrefixes is the most branch prefixes of a token. | |||
| 103 | + | MaxBranchPrefixes = 8 | |||
| 104 | + | ) | |||
| 105 | + | ||||
| 106 | + | // ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends | |||
| 107 | + | // with "/", the rest is a valid branch name, and it has at most | |||
| 108 | + | // MaxBranchPrefixLen bytes. Errors match ErrInvalidToken. | |||
| 109 | + | func ParseBranchPrefix(s string) (string, error) { | |||
| 110 | + | if len(s) > MaxBranchPrefixLen { | |||
| 111 | + | return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen) | |||
| 112 | + | } | |||
| 113 | + | name, ok := strings.CutSuffix(s, "/") | |||
| 114 | + | if !ok { | |||
| 115 | + | return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s) | |||
| 116 | + | } | |||
| 117 | + | if _, err := ParseBranchName(name); err != nil { | |||
| 118 | + | return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err) | |||
| 119 | + | } | |||
| 120 | + | return s, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // ParseBranchPrefixes validates a list of branch prefixes: at most | |||
| 124 | + | // MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match | |||
| 125 | + | // ErrInvalidToken. The result is nil for an empty list. | |||
| 126 | + | func ParseBranchPrefixes(in []string) ([]string, error) { | |||
| 127 | + | var out []string | |||
| 128 | + | for _, s := range in { | |||
| 129 | + | p, err := ParseBranchPrefix(s) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | if !slices.Contains(out, p) { | |||
| 134 | + | out = append(out, p) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | if len(out) > MaxBranchPrefixes { | |||
| 138 | + | return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes) | |||
| 139 | + | } | |||
| 140 | + | return out, nil | |||
| 141 | + | } | |||
M internal/domain/token_test.go +127 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package domain_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -111,3 +112,129 @@ func TestHasOwnerRights(t *testing.T) { | |||
| 111 | 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | |
| 112 | 113 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | |
| 113 | 114 | } | |
| 115 | + | ||
| 116 | + | func TestParseBranchPrefix(t *testing.T) { | |
| 117 | + | t.Parallel() | |
| 118 | + | for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} { | |
| 119 | + | got, err := domain.ParseBranchPrefix(ok) | |
| 120 | + | require.NoError(t, err, ok) | |
| 121 | + | assert.Equal(t, ok, got) | |
| 122 | + | } | |
| 123 | + | for _, bad := range []string{ | |
| 124 | + | "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/", | |
| 125 | + | "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/", | |
| 126 | + | "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/", | |
| 127 | + | } { | |
| 128 | + | _, err := domain.ParseBranchPrefix(bad) | |
| 129 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestParseBranchPrefixes(t *testing.T) { | |
| 134 | + | t.Parallel() | |
| 135 | + | got, err := domain.ParseBranchPrefixes(nil) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | assert.Empty(t, got) | |
| 138 | + | ||
| 139 | + | got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"}) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept") | |
| 142 | + | ||
| 143 | + | eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"} | |
| 144 | + | got, err = domain.ParseBranchPrefixes(eight) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | assert.Len(t, got, 8) | |
| 147 | + | _, err = domain.ParseBranchPrefixes(append(eight, "i/")) | |
| 148 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 149 | + | got, err = domain.ParseBranchPrefixes(append(eight, "a/")) | |
| 150 | + | require.NoError(t, err, "duplicates do not count") | |
| 151 | + | assert.Len(t, got, 8) | |
| 152 | + | ||
| 153 | + | _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"}) | |
| 154 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestAllowsRef(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | limited := domain.TokenPrincipal(domain.Token{ | |
| 160 | + | Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 161 | + | }) | |
| 162 | + | for ref, want := range map[string]bool{ | |
| 163 | + | "refs/heads/agent/claude/x": true, | |
| 164 | + | "refs/heads/agent/claude/a/b": true, | |
| 165 | + | "refs/heads/bot/y": true, | |
| 166 | + | "refs/heads/agent/claude": false, | |
| 167 | + | "refs/heads/agent/claude/": false, | |
| 168 | + | "refs/heads/agent/claudex/y": false, | |
| 169 | + | "refs/heads/main": false, | |
| 170 | + | "refs/heads/agent/other/x": false, | |
| 171 | + | "refs/heads/other/agent/claude/x": false, | |
| 172 | + | "refs/tags/v1": false, | |
| 173 | + | "refs/tags/agent/claude/x": false, | |
| 174 | + | "refs/notes/commits": false, | |
| 175 | + | "refs/remotes/agent/claude/x": false, | |
| 176 | + | "refs/changes/agent/claude/x": false, | |
| 177 | + | "agent/claude/x": false, | |
| 178 | + | "heads/agent/claude/x": false, | |
| 179 | + | "HEAD": false, | |
| 180 | + | "": false, | |
| 181 | + | "refs/heads/agent/claude/../main": false, | |
| 182 | + | "refs/heads/agent/claude/x.lock": false, | |
| 183 | + | "refs/heads/agent/claude/a b": false, | |
| 184 | + | "refs/heads//agent/claude/x": false, | |
| 185 | + | } { | |
| 186 | + | assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // A corrupt empty prefix still limits the token and allows nothing. | |
| 190 | + | corrupt := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{""}}) | |
| 191 | + | assert.False(t, corrupt.AllowsRef("refs/heads/main")) | |
| 192 | + | ||
| 193 | + | // Without prefixes every ref is allowed. | |
| 194 | + | unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | |
| 195 | + | assert.True(t, unlimited.AllowsRef("refs/tags/v1")) | |
| 196 | + | assert.True(t, unlimited.AllowsRef("refs/heads/main")) | |
| 197 | + | assert.True(t, domain.Root().AllowsRef("refs/tags/v1")) | |
| 198 | + | assert.True(t, domain.Owner().AllowsRef("refs/heads/main")) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestBranchPrefixesOfPrincipal(t *testing.T) { | |
| 202 | + | t.Parallel() | |
| 203 | + | tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}} | |
| 204 | + | p := domain.TokenPrincipal(tok) | |
| 205 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 206 | + | assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner") | |
| 207 | + | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights()) | |
| 208 | + | ||
| 209 | + | // The principal owns copies. | |
| 210 | + | tok.BranchPrefixes[0] = "b/" | |
| 211 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 212 | + | p.BranchPrefixes()[0] = "c/" | |
| 213 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 214 | + | assert.Empty(t, domain.Root().BranchPrefixes()) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestBranchLimitedAndCoversBranchPrefix(t *testing.T) { | |
| 218 | + | t.Parallel() | |
| 219 | + | free := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}) | |
| 220 | + | assert.False(t, free.IsBranchLimited()) | |
| 221 | + | assert.False(t, free.CoversBranchPrefix("agent/x"), "no prefixes cover nothing: callers test IsBranchLimited first") | |
| 222 | + | assert.False(t, domain.Root().IsBranchLimited()) | |
| 223 | + | assert.False(t, domain.Owner().IsBranchLimited()) | |
| 224 | + | assert.False(t, domain.Anonymous().IsBranchLimited()) | |
| 225 | + | ||
| 226 | + | limited := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/claude/", "bot/"}}) | |
| 227 | + | assert.True(t, limited.IsBranchLimited()) | |
| 228 | + | for s, want := range map[string]bool{ | |
| 229 | + | "agent/claude/x": true, "agent/claude/": true, "bot/y": true, | |
| 230 | + | "agent/claude": false, "agent/claudex/y": false, "main": false, "": false, "xbot/y": false, | |
| 231 | + | } { | |
| 232 | + | assert.Equal(t, want, limited.CoversBranchPrefix(s), "%q", s) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // A corrupt empty prefix still limits, and covers nothing. | |
| 236 | + | corrupt := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{""}}) | |
| 237 | + | assert.True(t, corrupt.IsBranchLimited()) | |
| 238 | + | assert.False(t, corrupt.CoversBranchPrefix("main")) | |
| 239 | + | assert.False(t, corrupt.CoversBranchPrefix("")) | |
| 240 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package domain_test | 3 | package domain_test | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "strings" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -111,3 +112,129 @@ func TestHasOwnerRights(t *testing.T) { | |||||
| 111 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | ||
| 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | 113 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | ||
| 113 | } | 114 | } | ||
| 115 | + | ||||
| 116 | + | func TestParseBranchPrefix(t *testing.T) { | |||
| 117 | + | t.Parallel() | |||
| 118 | + | for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} { | |||
| 119 | + | got, err := domain.ParseBranchPrefix(ok) | |||
| 120 | + | require.NoError(t, err, ok) | |||
| 121 | + | assert.Equal(t, ok, got) | |||
| 122 | + | } | |||
| 123 | + | for _, bad := range []string{ | |||
| 124 | + | "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/", | |||
| 125 | + | "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/", | |||
| 126 | + | "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/", | |||
| 127 | + | } { | |||
| 128 | + | _, err := domain.ParseBranchPrefix(bad) | |||
| 129 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestParseBranchPrefixes(t *testing.T) { | |||
| 134 | + | t.Parallel() | |||
| 135 | + | got, err := domain.ParseBranchPrefixes(nil) | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | assert.Empty(t, got) | |||
| 138 | + | ||||
| 139 | + | got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"}) | |||
| 140 | + | require.NoError(t, err) | |||
| 141 | + | assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept") | |||
| 142 | + | ||||
| 143 | + | eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"} | |||
| 144 | + | got, err = domain.ParseBranchPrefixes(eight) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | assert.Len(t, got, 8) | |||
| 147 | + | _, err = domain.ParseBranchPrefixes(append(eight, "i/")) | |||
| 148 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |||
| 149 | + | got, err = domain.ParseBranchPrefixes(append(eight, "a/")) | |||
| 150 | + | require.NoError(t, err, "duplicates do not count") | |||
| 151 | + | assert.Len(t, got, 8) | |||
| 152 | + | ||||
| 153 | + | _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"}) | |||
| 154 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestAllowsRef(t *testing.T) { | |||
| 158 | + | t.Parallel() | |||
| 159 | + | limited := domain.TokenPrincipal(domain.Token{ | |||
| 160 | + | Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |||
| 161 | + | }) | |||
| 162 | + | for ref, want := range map[string]bool{ | |||
| 163 | + | "refs/heads/agent/claude/x": true, | |||
| 164 | + | "refs/heads/agent/claude/a/b": true, | |||
| 165 | + | "refs/heads/bot/y": true, | |||
| 166 | + | "refs/heads/agent/claude": false, | |||
| 167 | + | "refs/heads/agent/claude/": false, | |||
| 168 | + | "refs/heads/agent/claudex/y": false, | |||
| 169 | + | "refs/heads/main": false, | |||
| 170 | + | "refs/heads/agent/other/x": false, | |||
| 171 | + | "refs/heads/other/agent/claude/x": false, | |||
| 172 | + | "refs/tags/v1": false, | |||
| 173 | + | "refs/tags/agent/claude/x": false, | |||
| 174 | + | "refs/notes/commits": false, | |||
| 175 | + | "refs/remotes/agent/claude/x": false, | |||
| 176 | + | "refs/changes/agent/claude/x": false, | |||
| 177 | + | "agent/claude/x": false, | |||
| 178 | + | "heads/agent/claude/x": false, | |||
| 179 | + | "HEAD": false, | |||
| 180 | + | "": false, | |||
| 181 | + | "refs/heads/agent/claude/../main": false, | |||
| 182 | + | "refs/heads/agent/claude/x.lock": false, | |||
| 183 | + | "refs/heads/agent/claude/a b": false, | |||
| 184 | + | "refs/heads//agent/claude/x": false, | |||
| 185 | + | } { | |||
| 186 | + | assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // A corrupt empty prefix still limits the token and allows nothing. | |||
| 190 | + | corrupt := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{""}}) | |||
| 191 | + | assert.False(t, corrupt.AllowsRef("refs/heads/main")) | |||
| 192 | + | ||||
| 193 | + | // Without prefixes every ref is allowed. | |||
| 194 | + | unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | |||
| 195 | + | assert.True(t, unlimited.AllowsRef("refs/tags/v1")) | |||
| 196 | + | assert.True(t, unlimited.AllowsRef("refs/heads/main")) | |||
| 197 | + | assert.True(t, domain.Root().AllowsRef("refs/tags/v1")) | |||
| 198 | + | assert.True(t, domain.Owner().AllowsRef("refs/heads/main")) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestBranchPrefixesOfPrincipal(t *testing.T) { | |||
| 202 | + | t.Parallel() | |||
| 203 | + | tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}} | |||
| 204 | + | p := domain.TokenPrincipal(tok) | |||
| 205 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |||
| 206 | + | assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner") | |||
| 207 | + | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights()) | |||
| 208 | + | ||||
| 209 | + | // The principal owns copies. | |||
| 210 | + | tok.BranchPrefixes[0] = "b/" | |||
| 211 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |||
| 212 | + | p.BranchPrefixes()[0] = "c/" | |||
| 213 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |||
| 214 | + | assert.Empty(t, domain.Root().BranchPrefixes()) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestBranchLimitedAndCoversBranchPrefix(t *testing.T) { | |||
| 218 | + | t.Parallel() | |||
| 219 | + | free := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}) | |||
| 220 | + | assert.False(t, free.IsBranchLimited()) | |||
| 221 | + | assert.False(t, free.CoversBranchPrefix("agent/x"), "no prefixes cover nothing: callers test IsBranchLimited first") | |||
| 222 | + | assert.False(t, domain.Root().IsBranchLimited()) | |||
| 223 | + | assert.False(t, domain.Owner().IsBranchLimited()) | |||
| 224 | + | assert.False(t, domain.Anonymous().IsBranchLimited()) | |||
| 225 | + | ||||
| 226 | + | limited := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/claude/", "bot/"}}) | |||
| 227 | + | assert.True(t, limited.IsBranchLimited()) | |||
| 228 | + | for s, want := range map[string]bool{ | |||
| 229 | + | "agent/claude/x": true, "agent/claude/": true, "bot/y": true, | |||
| 230 | + | "agent/claude": false, "agent/claudex/y": false, "main": false, "": false, "xbot/y": false, | |||
| 231 | + | } { | |||
| 232 | + | assert.Equal(t, want, limited.CoversBranchPrefix(s), "%q", s) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // A corrupt empty prefix still limits, and covers nothing. | |||
| 236 | + | corrupt := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{""}}) | |||
| 237 | + | assert.True(t, corrupt.IsBranchLimited()) | |||
| 238 | + | assert.False(t, corrupt.CoversBranchPrefix("main")) | |||
| 239 | + | assert.False(t, corrupt.CoversBranchPrefix("")) | |||
| 240 | + | } | |||
M internal/ports/dto.go +3 -0
| @@ -61,6 +61,9 @@ type CreateTokenParams struct { | |||
| 61 | 61 | Scopes []domain.Scope | |
| 62 | 62 | // Repos limits the token to these repositories; empty means every one. | |
| 63 | 63 | Repos []domain.RepoName | |
| 64 | + | // BranchPrefixes limits the token to the branches under these prefixes | |
| 65 | + | // (see domain.ParseBranchPrefix); empty means every branch. | |
| 66 | + | BranchPrefixes []string | |
| 64 | 67 | // ExpiresAt is the zero time for a token that never expires. | |
| 65 | 68 | ExpiresAt time.Time | |
| 66 | 69 | } | |
| @@ -61,6 +61,9 @@ type CreateTokenParams struct { | |||||
| 61 | Scopes []domain.Scope | 61 | Scopes []domain.Scope | ||
| 62 | // Repos limits the token to these repositories; empty means every one. | 62 | // Repos limits the token to these repositories; empty means every one. | ||
| 63 | Repos []domain.RepoName | 63 | Repos []domain.RepoName | ||
| 64 | + | // BranchPrefixes limits the token to the branches under these prefixes | |||
| 65 | + | // (see domain.ParseBranchPrefix); empty means every branch. | |||
| 66 | + | BranchPrefixes []string | |||
| 64 | // ExpiresAt is the zero time for a token that never expires. | 67 | // ExpiresAt is the zero time for a token that never expires. | ||
| 65 | ExpiresAt time.Time | 68 | ExpiresAt time.Time | ||
| 66 | } | 69 | } | ||
M internal/usecase/access.go +18 -6
| @@ -26,12 +26,18 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) | |||
| 26 | 26 | return p.Allows(domain.ScopeRepoRead, name) | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | - | // adminAccess is the access rule for changing or deleting a repository. A | |
| 30 | - | // caller that may not administer it learns nothing it could not already read: | |
| 31 | - | // not found when it cannot see the repository, unauthenticated when it is | |
| 32 | - | // anonymous, forbidden otherwise. | |
| 29 | + | // adminAccess is the access rule for changing or deleting a repository: | |
| 30 | + | // repo:admin on it, and no branch prefixes. A token limited to some branches | |
| 31 | + | // may not delete the repository (that deletes every branch, those outside its | |
| 32 | + | // prefixes included), nor change its default branch, visibility, description, | |
| 33 | + | // merge modes or failing-checks rule: these decide what happens to branches the | |
| 34 | + | // token may not write. A caller that may not administer it learns nothing it | |
| 35 | + | // could not already read: not found when it cannot see the repository, | |
| 36 | + | // unauthenticated when it is anonymous, forbidden otherwise. | |
| 33 | 37 | func adminAccess(p domain.Principal, repo domain.Repo) error { | |
| 34 | 38 | switch { | |
| 39 | + | case canAdmin(p, repo.Name) && p.IsBranchLimited(): | |
| 40 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not administer a repository", domain.ErrForbidden) | |
| 35 | 41 | case canAdmin(p, repo.Name): | |
| 36 | 42 | return nil | |
| 37 | 43 | case !canRead(p, repo): | |
| @@ -42,12 +48,18 @@ func adminAccess(p domain.Principal, repo domain.Repo) error { | |||
| 42 | 48 | return domain.ErrForbidden | |
| 43 | 49 | } | |
| 44 | 50 | ||
| 45 | - | // canWrite is the access rule for pushing to a repository, or creating it on | |
| 46 | - | // first push: repo:write on it. | |
| 51 | + | // canWrite is the access rule for pushing to a repository: repo:write on it. | |
| 47 | 52 | func canWrite(p domain.Principal, name domain.RepoName) bool { | |
| 48 | 53 | return p.Allows(domain.ScopeRepoWrite, name) | |
| 49 | 54 | } | |
| 50 | 55 | ||
| 56 | + | // canCreateRepo is the access rule for creating a repository, by the API or on | |
| 57 | + | // first push: repo:write on the name, and no branch prefixes. A token limited | |
| 58 | + | // to some branches may push to a repository that exists; it never makes one. | |
| 59 | + | func canCreateRepo(p domain.Principal, name domain.RepoName) bool { | |
| 60 | + | return canWrite(p, name) && !p.IsBranchLimited() | |
| 61 | + | } | |
| 62 | + | ||
| 51 | 63 | // canManageTokens is the access rule for creating, listing and revoking | |
| 52 | 64 | // stored tokens: token:admin, which root holds. | |
| 53 | 65 | func canManageTokens(p domain.Principal) bool { | |
| @@ -26,12 +26,18 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) | |||||
| 26 | return p.Allows(domain.ScopeRepoRead, name) | 26 | return p.Allows(domain.ScopeRepoRead, name) | ||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | - | // adminAccess is the access rule for changing or deleting a repository. A | 29 | + | // adminAccess is the access rule for changing or deleting a repository: |
| 30 | - | // caller that may not administer it learns nothing it could not already read: | 30 | + | // repo:admin on it, and no branch prefixes. A token limited to some branches |
| 31 | - | // not found when it cannot see the repository, unauthenticated when it is | 31 | + | // may not delete the repository (that deletes every branch, those outside its |
| 32 | - | // anonymous, forbidden otherwise. | 32 | + | // prefixes included), nor change its default branch, visibility, description, |
| 33 | + | // merge modes or failing-checks rule: these decide what happens to branches the | |||
| 34 | + | // token may not write. A caller that may not administer it learns nothing it | |||
| 35 | + | // could not already read: not found when it cannot see the repository, | |||
| 36 | + | // unauthenticated when it is anonymous, forbidden otherwise. | |||
| 33 | func adminAccess(p domain.Principal, repo domain.Repo) error { | 37 | func adminAccess(p domain.Principal, repo domain.Repo) error { | ||
| 34 | switch { | 38 | switch { | ||
| 39 | + | case canAdmin(p, repo.Name) && p.IsBranchLimited(): | |||
| 40 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not administer a repository", domain.ErrForbidden) | |||
| 35 | case canAdmin(p, repo.Name): | 41 | case canAdmin(p, repo.Name): | ||
| 36 | return nil | 42 | return nil | ||
| 37 | case !canRead(p, repo): | 43 | case !canRead(p, repo): | ||
| @@ -42,12 +48,18 @@ func adminAccess(p domain.Principal, repo domain.Repo) error { | |||||
| 42 | return domain.ErrForbidden | 48 | return domain.ErrForbidden | ||
| 43 | } | 49 | } | ||
| 44 | 50 | ||||
| 45 | - | // canWrite is the access rule for pushing to a repository, or creating it on | 51 | + | // canWrite is the access rule for pushing to a repository: repo:write on it. |
| 46 | - | // first push: repo:write on it. | |||
| 47 | func canWrite(p domain.Principal, name domain.RepoName) bool { | 52 | func canWrite(p domain.Principal, name domain.RepoName) bool { | ||
| 48 | return p.Allows(domain.ScopeRepoWrite, name) | 53 | return p.Allows(domain.ScopeRepoWrite, name) | ||
| 49 | } | 54 | } | ||
| 50 | 55 | ||||
| 56 | + | // canCreateRepo is the access rule for creating a repository, by the API or on | |||
| 57 | + | // first push: repo:write on the name, and no branch prefixes. A token limited | |||
| 58 | + | // to some branches may push to a repository that exists; it never makes one. | |||
| 59 | + | func canCreateRepo(p domain.Principal, name domain.RepoName) bool { | |||
| 60 | + | return canWrite(p, name) && !p.IsBranchLimited() | |||
| 61 | + | } | |||
| 62 | + | ||||
| 51 | // canManageTokens is the access rule for creating, listing and revoking | 63 | // canManageTokens is the access rule for creating, listing and revoking | ||
| 52 | // stored tokens: token:admin, which root holds. | 64 | // stored tokens: token:admin, which root holds. | ||
| 53 | func canManageTokens(p domain.Principal) bool { | 65 | func canManageTokens(p domain.Principal) bool { | ||
M internal/usecase/ci.go +26 -1
| @@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R | |||
| 462 | 462 | return repo, nil | |
| 463 | 463 | } | |
| 464 | 464 | ||
| 465 | + | // runWriteAccess is the branch rule of the write actions on a run, cancel and | |
| 466 | + | // re-run, which come after the ci:write check. A run is tied to the ref that | |
| 467 | + | // made it, and re-running it executes the workflow of that ref again while | |
| 468 | + | // cancelling it stops the checks of that ref. So, like a push, a merge or a | |
| 469 | + | // branch delete, these actions fall under the branch prefixes of a limited | |
| 470 | + | // token (ADR 0027): it may act only on the runs of branches under its prefixes, | |
| 471 | + | // and never on a run of a tag or of any other ref. Without prefixes, every run. | |
| 472 | + | // | |
| 473 | + | // Reading runs, jobs, logs and checks is not a write: it needs only the access | |
| 474 | + | // to read the repository, as for the code, whatever the prefixes. The caller | |
| 475 | + | // has seen the run by then, so the refusal is forbidden, not not found. | |
| 476 | + | func runWriteAccess(p domain.Principal, run domain.CIRun) error { | |
| 477 | + | if !p.AllowsRef(run.Ref) { | |
| 478 | + | return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref) | |
| 479 | + | } | |
| 480 | + | return nil | |
| 481 | + | } | |
| 482 | + | ||
| 465 | 483 | // canManageRunners is the access rule of runners: runner:register on a token | |
| 466 | 484 | // that is not restricted to repositories (a runner serves every repository), | |
| 467 | - | // or root. | |
| 485 | + | // or root. A token limited to branch prefixes never manages runners, whatever | |
| 486 | + | // scopes it holds: a runner runs the workflows of every branch of every | |
| 487 | + | // repository, with their code and secrets, so registering or removing one | |
| 488 | + | // reaches far beyond the prefixes (a replaced runner could also be a way to | |
| 489 | + | // take over the jobs of main). Like repository administration and owner | |
| 490 | + | // rights, it is out of reach of a limited token. | |
| 468 | 491 | func canManageRunners(p domain.Principal) error { | |
| 469 | 492 | switch { | |
| 470 | 493 | case p.IsAnonymous(): | |
| @@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error { | |||
| 473 | 496 | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | |
| 474 | 497 | case len(p.Repos()) > 0: | |
| 475 | 498 | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | |
| 499 | + | case p.IsBranchLimited(): | |
| 500 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden) | |
| 476 | 501 | } | |
| 477 | 502 | return nil | |
| 478 | 503 | } | |
| @@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R | |||||
| 462 | return repo, nil | 462 | return repo, nil | ||
| 463 | } | 463 | } | ||
| 464 | 464 | ||||
| 465 | + | // runWriteAccess is the branch rule of the write actions on a run, cancel and | |||
| 466 | + | // re-run, which come after the ci:write check. A run is tied to the ref that | |||
| 467 | + | // made it, and re-running it executes the workflow of that ref again while | |||
| 468 | + | // cancelling it stops the checks of that ref. So, like a push, a merge or a | |||
| 469 | + | // branch delete, these actions fall under the branch prefixes of a limited | |||
| 470 | + | // token (ADR 0027): it may act only on the runs of branches under its prefixes, | |||
| 471 | + | // and never on a run of a tag or of any other ref. Without prefixes, every run. | |||
| 472 | + | // | |||
| 473 | + | // Reading runs, jobs, logs and checks is not a write: it needs only the access | |||
| 474 | + | // to read the repository, as for the code, whatever the prefixes. The caller | |||
| 475 | + | // has seen the run by then, so the refusal is forbidden, not not found. | |||
| 476 | + | func runWriteAccess(p domain.Principal, run domain.CIRun) error { | |||
| 477 | + | if !p.AllowsRef(run.Ref) { | |||
| 478 | + | return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref) | |||
| 479 | + | } | |||
| 480 | + | return nil | |||
| 481 | + | } | |||
| 482 | + | ||||
| 465 | // canManageRunners is the access rule of runners: runner:register on a token | 483 | // canManageRunners is the access rule of runners: runner:register on a token | ||
| 466 | // that is not restricted to repositories (a runner serves every repository), | 484 | // that is not restricted to repositories (a runner serves every repository), | ||
| 467 | - | // or root. | 485 | + | // or root. A token limited to branch prefixes never manages runners, whatever |
| 486 | + | // scopes it holds: a runner runs the workflows of every branch of every | |||
| 487 | + | // repository, with their code and secrets, so registering or removing one | |||
| 488 | + | // reaches far beyond the prefixes (a replaced runner could also be a way to | |||
| 489 | + | // take over the jobs of main). Like repository administration and owner | |||
| 490 | + | // rights, it is out of reach of a limited token. | |||
| 468 | func canManageRunners(p domain.Principal) error { | 491 | func canManageRunners(p domain.Principal) error { | ||
| 469 | switch { | 492 | switch { | ||
| 470 | case p.IsAnonymous(): | 493 | case p.IsAnonymous(): | ||
| @@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error { | |||||
| 473 | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | 496 | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | ||
| 474 | case len(p.Repos()) > 0: | 497 | case len(p.Repos()) > 0: | ||
| 475 | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | 498 | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | ||
| 499 | + | case p.IsBranchLimited(): | |||
| 500 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden) | |||
| 476 | } | 501 | } | ||
| 477 | return nil | 502 | return nil | ||
| 478 | } | 503 | } | ||
M internal/usecase/ci_runs.go +12 -4
| @@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 318 | 318 | if _, err := c.writableRepo(ctx, p, name); err != nil { | |
| 319 | 319 | return domain.CIRun{}, err | |
| 320 | 320 | } | |
| 321 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | |
| 321 | + | run, err := c.runOf(ctx, name, runID) | |
| 322 | + | if err != nil { | |
| 323 | + | return domain.CIRun{}, err | |
| 324 | + | } | |
| 325 | + | if err := runWriteAccess(p, run); err != nil { | |
| 322 | 326 | return domain.CIRun{}, err | |
| 323 | 327 | } | |
| 324 | 328 | var out domain.CIRun | |
| 325 | - | err := c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 329 | + | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 326 | 330 | if rs.run.Status == domain.CICompleted { | |
| 327 | 331 | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | |
| 328 | 332 | } | |
| @@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 351 | 355 | if err != nil { | |
| 352 | 356 | return domain.CIRun{}, err | |
| 353 | 357 | } | |
| 354 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | |
| 358 | + | run, err := c.runOf(ctx, name, runID) | |
| 359 | + | if err != nil { | |
| 360 | + | return domain.CIRun{}, err | |
| 361 | + | } | |
| 362 | + | if err := runWriteAccess(p, run); err != nil { | |
| 355 | 363 | return domain.CIRun{}, err | |
| 356 | 364 | } | |
| 357 | 365 | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| @@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 416 | 424 | if err := c.advanceRun(ctx, runID); err != nil { | |
| 417 | 425 | return domain.CIRun{}, err | |
| 418 | 426 | } | |
| 419 | - | run, err := c.runOf(ctx, name, runID) | |
| 427 | + | run, err = c.runOf(ctx, name, runID) | |
| 420 | 428 | if err != nil { | |
| 421 | 429 | return domain.CIRun{}, err | |
| 422 | 430 | } | |
| @@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 318 | if _, err := c.writableRepo(ctx, p, name); err != nil { | 318 | if _, err := c.writableRepo(ctx, p, name); err != nil { | ||
| 319 | return domain.CIRun{}, err | 319 | return domain.CIRun{}, err | ||
| 320 | } | 320 | } | ||
| 321 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | 321 | + | run, err := c.runOf(ctx, name, runID) |
| 322 | + | if err != nil { | |||
| 323 | + | return domain.CIRun{}, err | |||
| 324 | + | } | |||
| 325 | + | if err := runWriteAccess(p, run); err != nil { | |||
| 322 | return domain.CIRun{}, err | 326 | return domain.CIRun{}, err | ||
| 323 | } | 327 | } | ||
| 324 | var out domain.CIRun | 328 | var out domain.CIRun | ||
| 325 | - | err := c.withRun(ctx, runID, 0, func(rs *runState) error { | 329 | + | err = c.withRun(ctx, runID, 0, func(rs *runState) error { |
| 326 | if rs.run.Status == domain.CICompleted { | 330 | if rs.run.Status == domain.CICompleted { | ||
| 327 | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | 331 | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | ||
| 328 | } | 332 | } | ||
| @@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||||
| 351 | if err != nil { | 355 | if err != nil { | ||
| 352 | return domain.CIRun{}, err | 356 | return domain.CIRun{}, err | ||
| 353 | } | 357 | } | ||
| 354 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | 358 | + | run, err := c.runOf(ctx, name, runID) |
| 359 | + | if err != nil { | |||
| 360 | + | return domain.CIRun{}, err | |||
| 361 | + | } | |||
| 362 | + | if err := runWriteAccess(p, run); err != nil { | |||
| 355 | return domain.CIRun{}, err | 363 | return domain.CIRun{}, err | ||
| 356 | } | 364 | } | ||
| 357 | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | 365 | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | ||
| @@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||||
| 416 | if err := c.advanceRun(ctx, runID); err != nil { | 424 | if err := c.advanceRun(ctx, runID); err != nil { | ||
| 417 | return domain.CIRun{}, err | 425 | return domain.CIRun{}, err | ||
| 418 | } | 426 | } | ||
| 419 | - | run, err := c.runOf(ctx, name, runID) | 427 | + | run, err = c.runOf(ctx, name, runID) |
| 420 | if err != nil { | 428 | if err != nil { | ||
| 421 | return domain.CIRun{}, err | 429 | return domain.CIRun{}, err | ||
| 422 | } | 430 | } | ||
M internal/usecase/mergerequests.go +8 -0
| @@ -718,6 +718,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 718 | 718 | if err != nil { | |
| 719 | 719 | return domain.MergeRequest{}, false, err | |
| 720 | 720 | } | |
| 721 | + | // A merge moves the target and may delete the source: a token limited to | |
| 722 | + | // branch prefixes may do so only within them. | |
| 723 | + | if !p.AllowsRef("refs/heads/" + mr.Target) { | |
| 724 | + | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |
| 725 | + | } | |
| 726 | + | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | |
| 727 | + | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | |
| 728 | + | } | |
| 721 | 729 | if !mr.IsOpen() { | |
| 722 | 730 | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | |
| 723 | 731 | } | |
| @@ -718,6 +718,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 718 | if err != nil { | 718 | if err != nil { | ||
| 719 | return domain.MergeRequest{}, false, err | 719 | return domain.MergeRequest{}, false, err | ||
| 720 | } | 720 | } | ||
| 721 | + | // A merge moves the target and may delete the source: a token limited to | |||
| 722 | + | // branch prefixes may do so only within them. | |||
| 723 | + | if !p.AllowsRef("refs/heads/" + mr.Target) { | |||
| 724 | + | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target) | |||
| 725 | + | } | |||
| 726 | + | if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) { | |||
| 727 | + | return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source) | |||
| 728 | + | } | |||
| 721 | if !mr.IsOpen() { | 729 | if !mr.IsOpen() { | ||
| 722 | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | 730 | return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State) | ||
| 723 | } | 731 | } | ||
A internal/usecase/mergerequests_branches_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // branchMerger holds mr:merge but may only write the branches under | |
| 16 | + | // agent/claude/. | |
| 17 | + | var branchMerger = domain.TokenPrincipal(domain.Token{ | |
| 18 | + | ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"}, | |
| 19 | + | Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, | |
| 20 | + | }) | |
| 21 | + | ||
| 22 | + | // AC5: a merge writes the target, and the source when it is deleted; a token | |
| 23 | + | // limited to branch prefixes may do neither outside them. | |
| 24 | + | func TestMergeWithBranchLimitedToken(t *testing.T) { | |
| 25 | + | t.Parallel() | |
| 26 | + | ||
| 27 | + | t.Run("into main is forbidden and nothing moves", func(t *testing.T) { | |
| 28 | + | t.Parallel() | |
| 29 | + | r := newMRRig(t) | |
| 30 | + | main := r.seed() | |
| 31 | + | head := r.feature("agent/claude/feat") | |
| 32 | + | mr := r.open("agent/claude/feat") // targets main | |
| 33 | + | r.drain() | |
| 34 | + | ||
| 35 | + | for _, del := range []bool{false, true} { | |
| 36 | + | _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del}) | |
| 37 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 38 | + | assert.Contains(t, err.Error(), "this token may not write main") | |
| 39 | + | } | |
| 40 | + | assert.Equal(t, main, r.branchHead("main"), "main did not move") | |
| 41 | + | assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays") | |
| 42 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 45 | + | assert.Empty(t, r.drain(), "no event") | |
| 46 | + | ||
| 47 | + | // The same merge by an unlimited token works. | |
| 48 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | assert.Equal(t, head, r.branchHead("main")) | |
| 51 | + | }) | |
| 52 | + | ||
| 53 | + | t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) { | |
| 54 | + | t.Parallel() | |
| 55 | + | r := newMRRig(t) | |
| 56 | + | r.seed() | |
| 57 | + | r.sh("checkout", "-q", "-B", "agent/claude/base", "main") | |
| 58 | + | r.push("agent/claude/base") | |
| 59 | + | r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base") | |
| 60 | + | head := r.commit(author, "f.txt", "f\n", "add f") | |
| 61 | + | r.push("agent/claude/feat") | |
| 62 | + | mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{ | |
| 63 | + | Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base", | |
| 64 | + | }) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | ||
| 67 | + | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | assert.True(t, res.SourceDeleted) | |
| 70 | + | assert.Equal(t, head, r.branchHead("agent/claude/base")) | |
| 71 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat") | |
| 72 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 73 | + | }) | |
| 74 | + | ||
| 75 | + | t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) { | |
| 76 | + | t.Parallel() | |
| 77 | + | r := newMRRig(t) | |
| 78 | + | r.seed() | |
| 79 | + | r.sh("checkout", "-q", "-B", "agent/claude/base", "main") | |
| 80 | + | r.push("agent/claude/base") | |
| 81 | + | base := r.branchHead("agent/claude/base") | |
| 82 | + | r.sh("checkout", "-q", "-B", "other", "agent/claude/base") | |
| 83 | + | head := r.commit(author, "o.txt", "o\n", "add o") | |
| 84 | + | r.push("other") | |
| 85 | + | mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{ | |
| 86 | + | Title: "other", Source: "other", Target: "agent/claude/base", | |
| 87 | + | }) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | ||
| 90 | + | _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |
| 91 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 92 | + | assert.Contains(t, err.Error(), "this token may not write other") | |
| 93 | + | assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged") | |
| 94 | + | assert.Equal(t, head, r.branchHead("other"), "the source stays") | |
| 95 | + | ||
| 96 | + | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | assert.False(t, res.SourceDeleted) | |
| 99 | + | assert.Equal(t, head, r.branchHead("agent/claude/base")) | |
| 100 | + | assert.Equal(t, head, r.branchHead("other"), "the source stays") | |
| 101 | + | }) | |
| 102 | + | ||
| 103 | + | t.Run("a squash into main is forbidden too", func(t *testing.T) { | |
| 104 | + | t.Parallel() | |
| 105 | + | r := newMRRig(t) | |
| 106 | + | main := r.seed() | |
| 107 | + | head := r.feature("agent/claude/feat") | |
| 108 | + | mr := r.open("agent/claude/feat") | |
| 109 | + | _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 110 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 111 | + | assert.Equal(t, main, r.branchHead("main")) | |
| 112 | + | }) | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | "bnema.dev/ermite/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // branchMerger holds mr:merge but may only write the branches under | |||
| 16 | + | // agent/claude/. | |||
| 17 | + | var branchMerger = domain.TokenPrincipal(domain.Token{ | |||
| 18 | + | ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"}, | |||
| 19 | + | Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, | |||
| 20 | + | }) | |||
| 21 | + | ||||
| 22 | + | // AC5: a merge writes the target, and the source when it is deleted; a token | |||
| 23 | + | // limited to branch prefixes may do neither outside them. | |||
| 24 | + | func TestMergeWithBranchLimitedToken(t *testing.T) { | |||
| 25 | + | t.Parallel() | |||
| 26 | + | ||||
| 27 | + | t.Run("into main is forbidden and nothing moves", func(t *testing.T) { | |||
| 28 | + | t.Parallel() | |||
| 29 | + | r := newMRRig(t) | |||
| 30 | + | main := r.seed() | |||
| 31 | + | head := r.feature("agent/claude/feat") | |||
| 32 | + | mr := r.open("agent/claude/feat") // targets main | |||
| 33 | + | r.drain() | |||
| 34 | + | ||||
| 35 | + | for _, del := range []bool{false, true} { | |||
| 36 | + | _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del}) | |||
| 37 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 38 | + | assert.Contains(t, err.Error(), "this token may not write main") | |||
| 39 | + | } | |||
| 40 | + | assert.Equal(t, main, r.branchHead("main"), "main did not move") | |||
| 41 | + | assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays") | |||
| 42 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |||
| 45 | + | assert.Empty(t, r.drain(), "no event") | |||
| 46 | + | ||||
| 47 | + | // The same merge by an unlimited token works. | |||
| 48 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | assert.Equal(t, head, r.branchHead("main")) | |||
| 51 | + | }) | |||
| 52 | + | ||||
| 53 | + | t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) { | |||
| 54 | + | t.Parallel() | |||
| 55 | + | r := newMRRig(t) | |||
| 56 | + | r.seed() | |||
| 57 | + | r.sh("checkout", "-q", "-B", "agent/claude/base", "main") | |||
| 58 | + | r.push("agent/claude/base") | |||
| 59 | + | r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base") | |||
| 60 | + | head := r.commit(author, "f.txt", "f\n", "add f") | |||
| 61 | + | r.push("agent/claude/feat") | |||
| 62 | + | mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{ | |||
| 63 | + | Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base", | |||
| 64 | + | }) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | ||||
| 67 | + | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | assert.True(t, res.SourceDeleted) | |||
| 70 | + | assert.Equal(t, head, r.branchHead("agent/claude/base")) | |||
| 71 | + | _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat") | |||
| 72 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 73 | + | }) | |||
| 74 | + | ||||
| 75 | + | t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) { | |||
| 76 | + | t.Parallel() | |||
| 77 | + | r := newMRRig(t) | |||
| 78 | + | r.seed() | |||
| 79 | + | r.sh("checkout", "-q", "-B", "agent/claude/base", "main") | |||
| 80 | + | r.push("agent/claude/base") | |||
| 81 | + | base := r.branchHead("agent/claude/base") | |||
| 82 | + | r.sh("checkout", "-q", "-B", "other", "agent/claude/base") | |||
| 83 | + | head := r.commit(author, "o.txt", "o\n", "add o") | |||
| 84 | + | r.push("other") | |||
| 85 | + | mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{ | |||
| 86 | + | Title: "other", Source: "other", Target: "agent/claude/base", | |||
| 87 | + | }) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | ||||
| 90 | + | _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true}) | |||
| 91 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 92 | + | assert.Contains(t, err.Error(), "this token may not write other") | |||
| 93 | + | assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged") | |||
| 94 | + | assert.Equal(t, head, r.branchHead("other"), "the source stays") | |||
| 95 | + | ||||
| 96 | + | res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | assert.False(t, res.SourceDeleted) | |||
| 99 | + | assert.Equal(t, head, r.branchHead("agent/claude/base")) | |||
| 100 | + | assert.Equal(t, head, r.branchHead("other"), "the source stays") | |||
| 101 | + | }) | |||
| 102 | + | ||||
| 103 | + | t.Run("a squash into main is forbidden too", func(t *testing.T) { | |||
| 104 | + | t.Parallel() | |||
| 105 | + | r := newMRRig(t) | |||
| 106 | + | main := r.seed() | |||
| 107 | + | head := r.feature("agent/claude/feat") | |||
| 108 | + | mr := r.open("agent/claude/feat") | |||
| 109 | + | _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |||
| 110 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |||
| 111 | + | assert.Equal(t, main, r.branchHead("main")) | |||
| 112 | + | }) | |||
| 113 | + | } | |||
M internal/usecase/pushes.go +18 -4
| @@ -77,7 +77,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 77 | 77 | return fmt.Errorf("get repository %q: %w", name, err) | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | if !create || !canWrite(p, name) { | |
| 80 | + | if !create || !canCreateRepo(p, name) { | |
| 81 | 81 | return domain.ErrNotFound | |
| 82 | 82 | } | |
| 83 | 83 | _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate}) | |
| @@ -105,9 +105,17 @@ func openExisting(p domain.Principal, repo domain.Repo) error { | |||
| 105 | 105 | return domain.ErrNotFound | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | - | // Check is the pre-receive decision. v1 accepts every push of a principal that | |
| 109 | - | // may write the repository; protected branches and limits will refuse here by | |
| 110 | - | // returning an error that matches domain.ErrRejected. | |
| 108 | + | // Check is the pre-receive decision. It accepts a push of a principal that | |
| 109 | + | // may write the repository. A token limited to branch prefixes may only update | |
| 110 | + | // (create, move or delete) branches under them: the first ref outside them | |
| 111 | + | // refuses the whole push, tags and every other ref included. Further rules | |
| 112 | + | // (protected branches, limits) will refuse here too, by returning an error | |
| 113 | + | // that matches domain.ErrRejected. | |
| 114 | + | // | |
| 115 | + | // The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no | |
| 116 | + | // symbolic ref under refs/heads besides HEAD (ermite never creates one and | |
| 117 | + | // receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees | |
| 118 | + | // every ref of the push, so a tag or any other ref is judged like a branch. | |
| 111 | 119 | // | |
| 112 | 120 | // The token of the push was valid when the request began, but a push can run | |
| 113 | 121 | // for long: it is re-validated here, so a token revoked or expired meanwhile | |
| @@ -122,6 +130,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 122 | 130 | if !canWrite(p, name) { | |
| 123 | 131 | return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name) | |
| 124 | 132 | } | |
| 133 | + | for _, up := range updates { | |
| 134 | + | if !p.AllowsRef(up.Ref) { | |
| 135 | + | return fmt.Errorf("%w: %s is outside the branches this token may push (%s)", | |
| 136 | + | domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", ")) | |
| 137 | + | } | |
| 138 | + | } | |
| 125 | 139 | log := zerowrap.FromCtx(ctx) | |
| 126 | 140 | log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked") | |
| 127 | 141 | return nil | |
| @@ -77,7 +77,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN | |||||
| 77 | return fmt.Errorf("get repository %q: %w", name, err) | 77 | return fmt.Errorf("get repository %q: %w", name, err) | ||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | - | if !create || !canWrite(p, name) { | 80 | + | if !create || !canCreateRepo(p, name) { |
| 81 | return domain.ErrNotFound | 81 | return domain.ErrNotFound | ||
| 82 | } | 82 | } | ||
| 83 | _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate}) | 83 | _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate}) | ||
| @@ -105,9 +105,17 @@ func openExisting(p domain.Principal, repo domain.Repo) error { | |||||
| 105 | return domain.ErrNotFound | 105 | return domain.ErrNotFound | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | - | // Check is the pre-receive decision. v1 accepts every push of a principal that | 108 | + | // Check is the pre-receive decision. It accepts a push of a principal that |
| 109 | - | // may write the repository; protected branches and limits will refuse here by | 109 | + | // may write the repository. A token limited to branch prefixes may only update |
| 110 | - | // returning an error that matches domain.ErrRejected. | 110 | + | // (create, move or delete) branches under them: the first ref outside them |
| 111 | + | // refuses the whole push, tags and every other ref included. Further rules | |||
| 112 | + | // (protected branches, limits) will refuse here too, by returning an error | |||
| 113 | + | // that matches domain.ErrRejected. | |||
| 114 | + | // | |||
| 115 | + | // The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no | |||
| 116 | + | // symbolic ref under refs/heads besides HEAD (ermite never creates one and | |||
| 117 | + | // receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees | |||
| 118 | + | // every ref of the push, so a tag or any other ref is judged like a branch. | |||
| 111 | // | 119 | // | ||
| 112 | // The token of the push was valid when the request began, but a push can run | 120 | // The token of the push was valid when the request began, but a push can run | ||
| 113 | // for long: it is re-validated here, so a token revoked or expired meanwhile | 121 | // for long: it is re-validated here, so a token revoked or expired meanwhile | ||
| @@ -122,6 +130,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 122 | if !canWrite(p, name) { | 130 | if !canWrite(p, name) { | ||
| 123 | return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name) | 131 | return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name) | ||
| 124 | } | 132 | } | ||
| 133 | + | for _, up := range updates { | |||
| 134 | + | if !p.AllowsRef(up.Ref) { | |||
| 135 | + | return fmt.Errorf("%w: %s is outside the branches this token may push (%s)", | |||
| 136 | + | domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", ")) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 125 | log := zerowrap.FromCtx(ctx) | 139 | log := zerowrap.FromCtx(ctx) | ||
| 126 | log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked") | 140 | log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked") | ||
| 127 | return nil | 141 | return nil | ||
M internal/usecase/pushes_test.go +83 -0
| @@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) { | |||
| 76 | 76 | {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound}, | |
| 77 | 77 | {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound}, | |
| 78 | 78 | {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound}, | |
| 79 | + | {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil}, | |
| 80 | + | {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound}, | |
| 81 | + | {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound}, | |
| 79 | 82 | } | |
| 80 | 83 | for _, tt := range tests { | |
| 81 | 84 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) { | |||
| 203 | 206 | }) | |
| 204 | 207 | } | |
| 205 | 208 | ||
| 209 | + | // AC5: a token limited to branch prefixes pushes only under them. | |
| 210 | + | func TestPushesCheckBranchPrefixes(t *testing.T) { | |
| 211 | + | t.Parallel() | |
| 212 | + | ctx := context.Background() | |
| 213 | + | n := name(t, "tools") | |
| 214 | + | limited := domain.TokenPrincipal(domain.Token{ | |
| 215 | + | ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 216 | + | }) | |
| 217 | + | up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} } | |
| 218 | + | ||
| 219 | + | tests := []struct { | |
| 220 | + | name string | |
| 221 | + | updates []domain.RefUpdate | |
| 222 | + | refused string // the ref named by the error; "" = accepted | |
| 223 | + | }{ | |
| 224 | + | {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""}, | |
| 225 | + | {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""}, | |
| 226 | + | {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""}, | |
| 227 | + | {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""}, | |
| 228 | + | {"several under the prefixes", []domain.RefUpdate{ | |
| 229 | + | up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"), | |
| 230 | + | }, ""}, | |
| 231 | + | {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"}, | |
| 232 | + | {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"}, | |
| 233 | + | {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"}, | |
| 234 | + | {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"}, | |
| 235 | + | {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"}, | |
| 236 | + | {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"}, | |
| 237 | + | {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"}, | |
| 238 | + | {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"}, | |
| 239 | + | {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"}, | |
| 240 | + | {"mixed push: one allowed, one forbidden", []domain.RefUpdate{ | |
| 241 | + | up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"), | |
| 242 | + | }, "refs/heads/main"}, | |
| 243 | + | {"mixed push, forbidden first", []domain.RefUpdate{ | |
| 244 | + | up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), | |
| 245 | + | }, "refs/heads/main"}, | |
| 246 | + | } | |
| 247 | + | for _, tt := range tests { | |
| 248 | + | t.Run(tt.name, func(t *testing.T) { | |
| 249 | + | t.Parallel() | |
| 250 | + | f := newPushFixture(t) | |
| 251 | + | f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil) | |
| 252 | + | err := f.pushes.Check(ctx, limited, n, tt.updates) | |
| 253 | + | if tt.refused == "" { | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | return | |
| 256 | + | } | |
| 257 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 258 | + | assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)") | |
| 259 | + | }) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | t.Run("a token without limit pushes anywhere", func(t *testing.T) { | |
| 263 | + | t.Parallel() | |
| 264 | + | f := newPushFixture(t) | |
| 265 | + | f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil) | |
| 266 | + | free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 267 | + | require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{ | |
| 268 | + | up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"), | |
| 269 | + | })) | |
| 270 | + | }) | |
| 271 | + | ||
| 272 | + | t.Run("root pushes anywhere", func(t *testing.T) { | |
| 273 | + | t.Parallel() | |
| 274 | + | f := newPushFixture(t) | |
| 275 | + | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")})) | |
| 276 | + | }) | |
| 277 | + | ||
| 278 | + | t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) { | |
| 279 | + | t.Parallel() | |
| 280 | + | f := newPushFixture(t) | |
| 281 | + | f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil) | |
| 282 | + | p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}}) | |
| 283 | + | err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")}) | |
| 284 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 285 | + | assert.Contains(t, err.Error(), "no write access") | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | ||
| 206 | 289 | func TestPushesRecord(t *testing.T) { | |
| 207 | 290 | t.Parallel() | |
| 208 | 291 | ctx := context.Background() | |
| @@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) { | |||||
| 76 | {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound}, | 76 | {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound}, | ||
| 77 | {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound}, | 77 | {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound}, | ||
| 78 | {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound}, | 78 | {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound}, | ||
| 79 | + | {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil}, | |||
| 80 | + | {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound}, | |||
| 81 | + | {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound}, | |||
| 79 | } | 82 | } | ||
| 80 | for _, tt := range tests { | 83 | for _, tt := range tests { | ||
| 81 | t.Run(tt.name, func(t *testing.T) { | 84 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) { | |||||
| 203 | }) | 206 | }) | ||
| 204 | } | 207 | } | ||
| 205 | 208 | ||||
| 209 | + | // AC5: a token limited to branch prefixes pushes only under them. | |||
| 210 | + | func TestPushesCheckBranchPrefixes(t *testing.T) { | |||
| 211 | + | t.Parallel() | |||
| 212 | + | ctx := context.Background() | |||
| 213 | + | n := name(t, "tools") | |||
| 214 | + | limited := domain.TokenPrincipal(domain.Token{ | |||
| 215 | + | ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"}, | |||
| 216 | + | }) | |||
| 217 | + | up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} } | |||
| 218 | + | ||||
| 219 | + | tests := []struct { | |||
| 220 | + | name string | |||
| 221 | + | updates []domain.RefUpdate | |||
| 222 | + | refused string // the ref named by the error; "" = accepted | |||
| 223 | + | }{ | |||
| 224 | + | {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""}, | |||
| 225 | + | {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""}, | |||
| 226 | + | {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""}, | |||
| 227 | + | {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""}, | |||
| 228 | + | {"several under the prefixes", []domain.RefUpdate{ | |||
| 229 | + | up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"), | |||
| 230 | + | }, ""}, | |||
| 231 | + | {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"}, | |||
| 232 | + | {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"}, | |||
| 233 | + | {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"}, | |||
| 234 | + | {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"}, | |||
| 235 | + | {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"}, | |||
| 236 | + | {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"}, | |||
| 237 | + | {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"}, | |||
| 238 | + | {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"}, | |||
| 239 | + | {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"}, | |||
| 240 | + | {"mixed push: one allowed, one forbidden", []domain.RefUpdate{ | |||
| 241 | + | up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"), | |||
| 242 | + | }, "refs/heads/main"}, | |||
| 243 | + | {"mixed push, forbidden first", []domain.RefUpdate{ | |||
| 244 | + | up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), | |||
| 245 | + | }, "refs/heads/main"}, | |||
| 246 | + | } | |||
| 247 | + | for _, tt := range tests { | |||
| 248 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 249 | + | t.Parallel() | |||
| 250 | + | f := newPushFixture(t) | |||
| 251 | + | f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil) | |||
| 252 | + | err := f.pushes.Check(ctx, limited, n, tt.updates) | |||
| 253 | + | if tt.refused == "" { | |||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | return | |||
| 256 | + | } | |||
| 257 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 258 | + | assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)") | |||
| 259 | + | }) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | t.Run("a token without limit pushes anywhere", func(t *testing.T) { | |||
| 263 | + | t.Parallel() | |||
| 264 | + | f := newPushFixture(t) | |||
| 265 | + | f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil) | |||
| 266 | + | free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}) | |||
| 267 | + | require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{ | |||
| 268 | + | up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"), | |||
| 269 | + | })) | |||
| 270 | + | }) | |||
| 271 | + | ||||
| 272 | + | t.Run("root pushes anywhere", func(t *testing.T) { | |||
| 273 | + | t.Parallel() | |||
| 274 | + | f := newPushFixture(t) | |||
| 275 | + | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")})) | |||
| 276 | + | }) | |||
| 277 | + | ||||
| 278 | + | t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) { | |||
| 279 | + | t.Parallel() | |||
| 280 | + | f := newPushFixture(t) | |||
| 281 | + | f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil) | |||
| 282 | + | p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}}) | |||
| 283 | + | err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")}) | |||
| 284 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 285 | + | assert.Contains(t, err.Error(), "no write access") | |||
| 286 | + | }) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 206 | func TestPushesRecord(t *testing.T) { | 289 | func TestPushesRecord(t *testing.T) { | ||
| 207 | t.Parallel() | 290 | t.Parallel() | ||
| 208 | ctx := context.Background() | 291 | ctx := context.Background() | ||
M internal/usecase/repos.go +4 -3
| @@ -45,8 +45,9 @@ func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos { | |||
| 45 | 45 | // after the metadata is written removes it again and discards the staged | |
| 46 | 46 | // repository. | |
| 47 | 47 | // | |
| 48 | - | // The principal needs repo:write on the name: anonymous callers get | |
| 49 | - | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | |
| 48 | + | // The principal needs repo:write on the name and no branch prefixes: anonymous | |
| 49 | + | // callers get domain.ErrUnauthenticated, other callers without it (a token | |
| 50 | + | // limited to some branches included) domain.ErrForbidden. | |
| 50 | 51 | func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { | |
| 51 | 52 | if params.Name.IsZero() { | |
| 52 | 53 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| @@ -54,7 +55,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre | |||
| 54 | 55 | if p.IsAnonymous() { | |
| 55 | 56 | return domain.Repo{}, domain.ErrUnauthenticated | |
| 56 | 57 | } | |
| 57 | - | if !canWrite(p, params.Name) { | |
| 58 | + | if !canCreateRepo(p, params.Name) { | |
| 58 | 59 | return domain.Repo{}, domain.ErrForbidden | |
| 59 | 60 | } | |
| 60 | 61 | if err := validateDescription(params.Description); err != nil { | |
| @@ -45,8 +45,9 @@ func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos { | |||||
| 45 | // after the metadata is written removes it again and discards the staged | 45 | // after the metadata is written removes it again and discards the staged | ||
| 46 | // repository. | 46 | // repository. | ||
| 47 | // | 47 | // | ||
| 48 | - | // The principal needs repo:write on the name: anonymous callers get | 48 | + | // The principal needs repo:write on the name and no branch prefixes: anonymous |
| 49 | - | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | 49 | + | // callers get domain.ErrUnauthenticated, other callers without it (a token |
| 50 | + | // limited to some branches included) domain.ErrForbidden. | |||
| 50 | func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { | 51 | func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { | ||
| 51 | if params.Name.IsZero() { | 52 | if params.Name.IsZero() { | ||
| 52 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | 53 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | ||
| @@ -54,7 +55,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre | |||||
| 54 | if p.IsAnonymous() { | 55 | if p.IsAnonymous() { | ||
| 55 | return domain.Repo{}, domain.ErrUnauthenticated | 56 | return domain.Repo{}, domain.ErrUnauthenticated | ||
| 56 | } | 57 | } | ||
| 57 | - | if !canWrite(p, params.Name) { | 58 | + | if !canCreateRepo(p, params.Name) { |
| 58 | return domain.Repo{}, domain.ErrForbidden | 59 | return domain.Repo{}, domain.ErrForbidden | ||
| 59 | } | 60 | } | ||
| 60 | if err := validateDescription(params.Description); err != nil { | 61 | if err := validateDescription(params.Description); err != nil { | ||
M internal/usecase/repos_test.go +26 -2
| @@ -86,6 +86,18 @@ func TestCreate(t *testing.T) { | |||
| 86 | 86 | } | |
| 87 | 87 | }) | |
| 88 | 88 | ||
| 89 | + | t.Run("a token limited to branches may not create a repository", func(t *testing.T) { | |
| 90 | + | t.Parallel() | |
| 91 | + | f := newFixture(t) // the mocks expect no call: nothing is staged | |
| 92 | + | for _, p := range []domain.Principal{ | |
| 93 | + | branchLimited(domain.ScopeRepoWrite), | |
| 94 | + | branchLimited(domain.ScopeRepoWrite, "tools"), | |
| 95 | + | } { | |
| 96 | + | _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")}) | |
| 97 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | ||
| 89 | 101 | t.Run("token with repo:write creates", func(t *testing.T) { | |
| 90 | 102 | t.Parallel() | |
| 91 | 103 | f := newFixture(t) | |
| @@ -179,8 +191,10 @@ func TestCreate(t *testing.T) { | |||
| 179 | 191 | }) | |
| 180 | 192 | } | |
| 181 | 193 | ||
| 182 | - | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |
| 183 | - | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}} | |
| 194 | + | // scopedToken is the principal of a token with one scope, limited to the | |
| 195 | + | // repositories and to the branch prefixes given (none means no limit). | |
| 196 | + | func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal { | |
| 197 | + | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes} | |
| 184 | 198 | for _, r := range repos { | |
| 185 | 199 | n, err := domain.ParseRepoName(r) | |
| 186 | 200 | if err != nil { | |
| @@ -191,6 +205,16 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |||
| 191 | 205 | return domain.TokenPrincipal(tok) | |
| 192 | 206 | } | |
| 193 | 207 | ||
| 208 | + | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |
| 209 | + | return scopedToken(s, nil, repos...) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // branchLimited is a token with one scope, optionally limited to repositories, | |
| 213 | + | // and limited to the branches under "agent/". | |
| 214 | + | func branchLimited(s domain.Scope, repos ...string) domain.Principal { | |
| 215 | + | return scopedToken(s, []string{"agent/"}, repos...) | |
| 216 | + | } | |
| 217 | + | ||
| 194 | 218 | func TestGetAndListAccess(t *testing.T) { | |
| 195 | 219 | t.Parallel() | |
| 196 | 220 | ctx := context.Background() | |
| @@ -86,6 +86,18 @@ func TestCreate(t *testing.T) { | |||||
| 86 | } | 86 | } | ||
| 87 | }) | 87 | }) | ||
| 88 | 88 | ||||
| 89 | + | t.Run("a token limited to branches may not create a repository", func(t *testing.T) { | |||
| 90 | + | t.Parallel() | |||
| 91 | + | f := newFixture(t) // the mocks expect no call: nothing is staged | |||
| 92 | + | for _, p := range []domain.Principal{ | |||
| 93 | + | branchLimited(domain.ScopeRepoWrite), | |||
| 94 | + | branchLimited(domain.ScopeRepoWrite, "tools"), | |||
| 95 | + | } { | |||
| 96 | + | _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")}) | |||
| 97 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |||
| 98 | + | } | |||
| 99 | + | }) | |||
| 100 | + | ||||
| 89 | t.Run("token with repo:write creates", func(t *testing.T) { | 101 | t.Run("token with repo:write creates", func(t *testing.T) { | ||
| 90 | t.Parallel() | 102 | t.Parallel() | ||
| 91 | f := newFixture(t) | 103 | f := newFixture(t) | ||
| @@ -179,8 +191,10 @@ func TestCreate(t *testing.T) { | |||||
| 179 | }) | 191 | }) | ||
| 180 | } | 192 | } | ||
| 181 | 193 | ||||
| 182 | - | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | 194 | + | // scopedToken is the principal of a token with one scope, limited to the |
| 183 | - | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}} | 195 | + | // repositories and to the branch prefixes given (none means no limit). |
| 196 | + | func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal { | |||
| 197 | + | tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes} | |||
| 184 | for _, r := range repos { | 198 | for _, r := range repos { | ||
| 185 | n, err := domain.ParseRepoName(r) | 199 | n, err := domain.ParseRepoName(r) | ||
| 186 | if err != nil { | 200 | if err != nil { | ||
| @@ -191,6 +205,16 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |||||
| 191 | return domain.TokenPrincipal(tok) | 205 | return domain.TokenPrincipal(tok) | ||
| 192 | } | 206 | } | ||
| 193 | 207 | ||||
| 208 | + | func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal { | |||
| 209 | + | return scopedToken(s, nil, repos...) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // branchLimited is a token with one scope, optionally limited to repositories, | |||
| 213 | + | // and limited to the branches under "agent/". | |||
| 214 | + | func branchLimited(s domain.Scope, repos ...string) domain.Principal { | |||
| 215 | + | return scopedToken(s, []string{"agent/"}, repos...) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 194 | func TestGetAndListAccess(t *testing.T) { | 218 | func TestGetAndListAccess(t *testing.T) { | ||
| 195 | t.Parallel() | 219 | t.Parallel() | ||
| 196 | ctx := context.Background() | 220 | ctx := context.Background() | ||
M internal/usecase/repos_write.go +2 -2
| @@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err | |||
| 46 | 46 | ||
| 47 | 47 | // Update changes the description, the visibility, the default branch and the | |
| 48 | 48 | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it | |
| 49 | - | // (see adminAccess for what the others learn). At least one field must be set, | |
| 49 | + | // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set, | |
| 50 | 50 | // else domain.ErrInvalid. A default branch that does not exist gives | |
| 51 | 51 | // domain.ErrPreconditionFailed; nothing is written before every check passed. | |
| 52 | 52 | // The metadata is written first, then HEAD, and RepoUpdated is published after | |
| @@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 174 | 174 | ||
| 175 | 175 | // Delete moves the repository directory to the trash, removes its metadata | |
| 176 | 176 | // and publishes RepoDeleted. The principal needs repo:admin on it (see | |
| 177 | - | // adminAccess). A repository whose directory is already gone, left by a | |
| 177 | + | // adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a | |
| 178 | 178 | // delete that failed half way, loses its metadata. | |
| 179 | 179 | func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error { | |
| 180 | 180 | repo, err := lookupRepo(ctx, r.meta, name) | |
| @@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err | |||||
| 46 | 46 | ||||
| 47 | // Update changes the description, the visibility, the default branch and the | 47 | // Update changes the description, the visibility, the default branch and the | ||
| 48 | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it | 48 | // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it | ||
| 49 | - | // (see adminAccess for what the others learn). At least one field must be set, | 49 | + | // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set, |
| 50 | // else domain.ErrInvalid. A default branch that does not exist gives | 50 | // else domain.ErrInvalid. A default branch that does not exist gives | ||
| 51 | // domain.ErrPreconditionFailed; nothing is written before every check passed. | 51 | // domain.ErrPreconditionFailed; nothing is written before every check passed. | ||
| 52 | // The metadata is written first, then HEAD, and RepoUpdated is published after | 52 | // The metadata is written first, then HEAD, and RepoUpdated is published after | ||
| @@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||||
| 174 | 174 | ||||
| 175 | // Delete moves the repository directory to the trash, removes its metadata | 175 | // Delete moves the repository directory to the trash, removes its metadata | ||
| 176 | // and publishes RepoDeleted. The principal needs repo:admin on it (see | 176 | // and publishes RepoDeleted. The principal needs repo:admin on it (see | ||
| 177 | - | // adminAccess). A repository whose directory is already gone, left by a | 177 | + | // adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a |
| 178 | // delete that failed half way, loses its metadata. | 178 | // delete that failed half way, loses its metadata. | ||
| 179 | func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error { | 179 | func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error { | ||
| 180 | repo, err := lookupRepo(ctx, r.meta, name) | 180 | repo, err := lookupRepo(ctx, r.meta, name) | ||
M internal/usecase/repos_write_test.go +26 -0
| @@ -165,6 +165,32 @@ func TestUpdate(t *testing.T) { | |||
| 165 | 165 | }) | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | + | t.Run("a token limited to branches may not change any setting", func(t *testing.T) { | |
| 169 | + | t.Parallel() | |
| 170 | + | modes := []domain.MergeMode{domain.MergeModeSquash} | |
| 171 | + | block := true | |
| 172 | + | for name, params := range map[string]ports.UpdateRepoParams{ | |
| 173 | + | "default branch": {DefaultBranch: ptr("dev")}, | |
| 174 | + | "visibility": {Visibility: ptr(domain.VisibilityPublic)}, | |
| 175 | + | "description": {Description: ptr("x")}, | |
| 176 | + | "merge modes": {AllowedMergeModes: &modes}, | |
| 177 | + | "checks rule": {FailingChecksBlock: &block}, | |
| 178 | + | } { | |
| 179 | + | f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish | |
| 180 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 181 | + | _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params) | |
| 182 | + | assert.ErrorIs(t, err, domain.ErrForbidden, name) | |
| 183 | + | } | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | t.Run("a token limited to branches may not delete a repository", func(t *testing.T) { | |
| 187 | + | t.Parallel() | |
| 188 | + | f := newFixture(t) // no Trash, no Delete | |
| 189 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 190 | + | err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n) | |
| 191 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 192 | + | }) | |
| 193 | + | ||
| 168 | 194 | t.Run("a missing repository is not found", func(t *testing.T) { | |
| 169 | 195 | t.Parallel() | |
| 170 | 196 | f := newFixture(t) | |
| @@ -165,6 +165,32 @@ func TestUpdate(t *testing.T) { | |||||
| 165 | }) | 165 | }) | ||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | + | t.Run("a token limited to branches may not change any setting", func(t *testing.T) { | |||
| 169 | + | t.Parallel() | |||
| 170 | + | modes := []domain.MergeMode{domain.MergeModeSquash} | |||
| 171 | + | block := true | |||
| 172 | + | for name, params := range map[string]ports.UpdateRepoParams{ | |||
| 173 | + | "default branch": {DefaultBranch: ptr("dev")}, | |||
| 174 | + | "visibility": {Visibility: ptr(domain.VisibilityPublic)}, | |||
| 175 | + | "description": {Description: ptr("x")}, | |||
| 176 | + | "merge modes": {AllowedMergeModes: &modes}, | |||
| 177 | + | "checks rule": {FailingChecksBlock: &block}, | |||
| 178 | + | } { | |||
| 179 | + | f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish | |||
| 180 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |||
| 181 | + | _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params) | |||
| 182 | + | assert.ErrorIs(t, err, domain.ErrForbidden, name) | |||
| 183 | + | } | |||
| 184 | + | }) | |||
| 185 | + | ||||
| 186 | + | t.Run("a token limited to branches may not delete a repository", func(t *testing.T) { | |||
| 187 | + | t.Parallel() | |||
| 188 | + | f := newFixture(t) // no Trash, no Delete | |||
| 189 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |||
| 190 | + | err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n) | |||
| 191 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |||
| 192 | + | }) | |||
| 193 | + | ||||
| 168 | t.Run("a missing repository is not found", func(t *testing.T) { | 194 | t.Run("a missing repository is not found", func(t *testing.T) { | ||
| 169 | t.Parallel() | 195 | t.Parallel() | ||
| 170 | f := newFixture(t) | 196 | f := newFixture(t) | ||
M internal/usecase/sessions.go +2 -2
| @@ -74,13 +74,13 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | // requireOwner is the access rule of the session API: root, or a stored token | |
| 77 | - | // with every scope and no repository list. | |
| 77 | + | // with every scope, no repository list and no branch prefixes. | |
| 78 | 78 | func requireOwner(p domain.Principal) error { | |
| 79 | 79 | switch { | |
| 80 | 80 | case p.IsAnonymous(): | |
| 81 | 81 | return domain.ErrUnauthenticated | |
| 82 | 82 | case !p.HasOwnerRights(): | |
| 83 | - | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden) | |
| 83 | + | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden) | |
| 84 | 84 | } | |
| 85 | 85 | return nil | |
| 86 | 86 | } | |
| @@ -74,13 +74,13 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | // requireOwner is the access rule of the session API: root, or a stored token | 76 | // requireOwner is the access rule of the session API: root, or a stored token | ||
| 77 | - | // with every scope and no repository list. | 77 | + | // with every scope, no repository list and no branch prefixes. |
| 78 | func requireOwner(p domain.Principal) error { | 78 | func requireOwner(p domain.Principal) error { | ||
| 79 | switch { | 79 | switch { | ||
| 80 | case p.IsAnonymous(): | 80 | case p.IsAnonymous(): | ||
| 81 | return domain.ErrUnauthenticated | 81 | return domain.ErrUnauthenticated | ||
| 82 | case !p.HasOwnerRights(): | 82 | case !p.HasOwnerRights(): | ||
| 83 | - | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden) | 83 | + | return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden) |
| 84 | } | 84 | } | ||
| 85 | return nil | 85 | return nil | ||
| 86 | } | 86 | } | ||
M internal/usecase/tokens.go +26 -9
| @@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session | |||
| 54 | 54 | // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token | |
| 55 | 55 | // cannot create a token more powerful than itself: every scope must be one it | |
| 56 | 56 | // holds, the repositories must lie within its list (a limited token cannot | |
| 57 | - | // create an unlimited one) and the expiry must not outlast its own. Violations | |
| 57 | + | // create an unlimited one), the same for branch prefixes (each must start | |
| 58 | + | // with one of its own) and the expiry must not outlast its own. Violations | |
| 58 | 59 | // give domain.ErrForbidden. Root has no such limits. Invalid settings give | |
| 59 | 60 | // domain.ErrInvalidToken (this includes the labels "owner" and "root", which | |
| 60 | 61 | // are reserved, in any case and around spaces), an unknown scope | |
| @@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr | |||
| 88 | 89 | log := zerowrap.FromCtx(ctx) | |
| 89 | 90 | log.Info(). | |
| 90 | 91 | Str("token_id", tok.ID).Str("label", tok.Label). | |
| 91 | - | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)). | |
| 92 | + | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)). | |
| 92 | 93 | Str("created_by", p.Label()).Str("created_by_token", p.TokenID()). | |
| 93 | 94 | Msg("token created") | |
| 94 | 95 | return ports.CreatedToken{Token: tok, Secret: secret}, nil | |
| @@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er | |||
| 122 | 123 | if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) { | |
| 123 | 124 | return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken) | |
| 124 | 125 | } | |
| 126 | + | prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes) | |
| 127 | + | if err != nil { | |
| 128 | + | return domain.Token{}, err | |
| 129 | + | } | |
| 125 | 130 | tok := domain.Token{ | |
| 126 | - | Label: params.Label, | |
| 127 | - | CreatedAt: now, | |
| 128 | - | ExpiresAt: params.ExpiresAt.UTC(), | |
| 131 | + | Label: params.Label, | |
| 132 | + | BranchPrefixes: prefixes, | |
| 133 | + | CreatedAt: now, | |
| 134 | + | ExpiresAt: params.ExpiresAt.UTC(), | |
| 129 | 135 | } | |
| 130 | 136 | // Canonical order, no duplicates. | |
| 131 | 137 | for _, s := range domain.AllScopes() { | |
| @@ -143,8 +149,9 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er | |||
| 143 | 149 | } | |
| 144 | 150 | ||
| 145 | 151 | // checkNoEscalation enforces that tok is within the rights of p: every scope | |
| 146 | - | // held by p, the repositories within its list and the expiry not beyond its | |
| 147 | - | // own. Root has no limits. | |
| 152 | + | // held by p, the repositories within its list, the branch prefixes within its | |
| 153 | + | // own (a limited p only makes tokens that are limited too, each prefix under | |
| 154 | + | // one of its own) and the expiry not beyond its own. Root has no limits. | |
| 148 | 155 | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |
| 149 | 156 | if p.IsRoot() { | |
| 150 | 157 | return nil | |
| @@ -164,6 +171,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||
| 164 | 171 | } | |
| 165 | 172 | } | |
| 166 | 173 | } | |
| 174 | + | if p.IsBranchLimited() { | |
| 175 | + | if len(tok.BranchPrefixes) == 0 { | |
| 176 | + | return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden) | |
| 177 | + | } | |
| 178 | + | for _, bp := range tok.BranchPrefixes { | |
| 179 | + | if !p.CoversBranchPrefix(bp) { | |
| 180 | + | return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 167 | 184 | if exp := p.ExpiresAt(); !exp.IsZero() { | |
| 168 | 185 | if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) { | |
| 169 | 186 | return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden) | |
| @@ -173,7 +190,7 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||
| 173 | 190 | } | |
| 174 | 191 | ||
| 175 | 192 | // List returns the stored tokens the principal could have created, oldest | |
| 176 | - | // first: those whose scopes, repositories and expiry lie within its own (the | |
| 193 | + | // first: those whose scopes, repositories, branch prefixes and expiry lie within its own (the | |
| 177 | 194 | // no-escalation rule of Create). Root sees every token. It needs token:admin. | |
| 178 | 195 | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | |
| 179 | 196 | if err := requireTokenAdmin(p); err != nil { | |
| @@ -195,7 +212,7 @@ func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, | |||
| 195 | 212 | // Create), so that it does not show itself. Root may revoke any token. | |
| 196 | 213 | // | |
| 197 | 214 | // A token that has owner rights (HasOwnerRights: every scope, no repository | |
| 198 | - | // list) could open web sessions, so revoking it also signs every browser out, | |
| 215 | + | // list and no branch prefixes) could open web sessions, so revoking it also signs every browser out, | |
| 199 | 216 | // as `ermite web logout --all` does: web sessions are not tied to the token | |
| 200 | 217 | // that opened them. The revocation holds even if signing out fails, which is | |
| 201 | 218 | // then reported as an error. | |
| @@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session | |||||
| 54 | // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token | 54 | // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token | ||
| 55 | // cannot create a token more powerful than itself: every scope must be one it | 55 | // cannot create a token more powerful than itself: every scope must be one it | ||
| 56 | // holds, the repositories must lie within its list (a limited token cannot | 56 | // holds, the repositories must lie within its list (a limited token cannot | ||
| 57 | - | // create an unlimited one) and the expiry must not outlast its own. Violations | 57 | + | // create an unlimited one), the same for branch prefixes (each must start |
| 58 | + | // with one of its own) and the expiry must not outlast its own. Violations | |||
| 58 | // give domain.ErrForbidden. Root has no such limits. Invalid settings give | 59 | // give domain.ErrForbidden. Root has no such limits. Invalid settings give | ||
| 59 | // domain.ErrInvalidToken (this includes the labels "owner" and "root", which | 60 | // domain.ErrInvalidToken (this includes the labels "owner" and "root", which | ||
| 60 | // are reserved, in any case and around spaces), an unknown scope | 61 | // are reserved, in any case and around spaces), an unknown scope | ||
| @@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr | |||||
| 88 | log := zerowrap.FromCtx(ctx) | 89 | log := zerowrap.FromCtx(ctx) | ||
| 89 | log.Info(). | 90 | log.Info(). | ||
| 90 | Str("token_id", tok.ID).Str("label", tok.Label). | 91 | Str("token_id", tok.ID).Str("label", tok.Label). | ||
| 91 | - | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)). | 92 | + | Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)). |
| 92 | Str("created_by", p.Label()).Str("created_by_token", p.TokenID()). | 93 | Str("created_by", p.Label()).Str("created_by_token", p.TokenID()). | ||
| 93 | Msg("token created") | 94 | Msg("token created") | ||
| 94 | return ports.CreatedToken{Token: tok, Secret: secret}, nil | 95 | return ports.CreatedToken{Token: tok, Secret: secret}, nil | ||
| @@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er | |||||
| 122 | if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) { | 123 | if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) { | ||
| 123 | return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken) | 124 | return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken) | ||
| 124 | } | 125 | } | ||
| 126 | + | prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes) | |||
| 127 | + | if err != nil { | |||
| 128 | + | return domain.Token{}, err | |||
| 129 | + | } | |||
| 125 | tok := domain.Token{ | 130 | tok := domain.Token{ | ||
| 126 | - | Label: params.Label, | 131 | + | Label: params.Label, |
| 127 | - | CreatedAt: now, | 132 | + | BranchPrefixes: prefixes, |
| 128 | - | ExpiresAt: params.ExpiresAt.UTC(), | 133 | + | CreatedAt: now, |
| 134 | + | ExpiresAt: params.ExpiresAt.UTC(), | |||
| 129 | } | 135 | } | ||
| 130 | // Canonical order, no duplicates. | 136 | // Canonical order, no duplicates. | ||
| 131 | for _, s := range domain.AllScopes() { | 137 | for _, s := range domain.AllScopes() { | ||
| @@ -143,8 +149,9 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er | |||||
| 143 | } | 149 | } | ||
| 144 | 150 | ||||
| 145 | // checkNoEscalation enforces that tok is within the rights of p: every scope | 151 | // checkNoEscalation enforces that tok is within the rights of p: every scope | ||
| 146 | - | // held by p, the repositories within its list and the expiry not beyond its | 152 | + | // held by p, the repositories within its list, the branch prefixes within its |
| 147 | - | // own. Root has no limits. | 153 | + | // own (a limited p only makes tokens that are limited too, each prefix under |
| 154 | + | // one of its own) and the expiry not beyond its own. Root has no limits. | |||
| 148 | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | 155 | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | ||
| 149 | if p.IsRoot() { | 156 | if p.IsRoot() { | ||
| 150 | return nil | 157 | return nil | ||
| @@ -164,6 +171,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||||
| 164 | } | 171 | } | ||
| 165 | } | 172 | } | ||
| 166 | } | 173 | } | ||
| 174 | + | if p.IsBranchLimited() { | |||
| 175 | + | if len(tok.BranchPrefixes) == 0 { | |||
| 176 | + | return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden) | |||
| 177 | + | } | |||
| 178 | + | for _, bp := range tok.BranchPrefixes { | |||
| 179 | + | if !p.CoversBranchPrefix(bp) { | |||
| 180 | + | return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 167 | if exp := p.ExpiresAt(); !exp.IsZero() { | 184 | if exp := p.ExpiresAt(); !exp.IsZero() { | ||
| 168 | if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) { | 185 | if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) { | ||
| 169 | return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden) | 186 | return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden) | ||
| @@ -173,7 +190,7 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||||
| 173 | } | 190 | } | ||
| 174 | 191 | ||||
| 175 | // List returns the stored tokens the principal could have created, oldest | 192 | // List returns the stored tokens the principal could have created, oldest | ||
| 176 | - | // first: those whose scopes, repositories and expiry lie within its own (the | 193 | + | // first: those whose scopes, repositories, branch prefixes and expiry lie within its own (the |
| 177 | // no-escalation rule of Create). Root sees every token. It needs token:admin. | 194 | // no-escalation rule of Create). Root sees every token. It needs token:admin. | ||
| 178 | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | 195 | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | ||
| 179 | if err := requireTokenAdmin(p); err != nil { | 196 | if err := requireTokenAdmin(p); err != nil { | ||
| @@ -195,7 +212,7 @@ func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, | |||||
| 195 | // Create), so that it does not show itself. Root may revoke any token. | 212 | // Create), so that it does not show itself. Root may revoke any token. | ||
| 196 | // | 213 | // | ||
| 197 | // A token that has owner rights (HasOwnerRights: every scope, no repository | 214 | // A token that has owner rights (HasOwnerRights: every scope, no repository | ||
| 198 | - | // list) could open web sessions, so revoking it also signs every browser out, | 215 | + | // list and no branch prefixes) could open web sessions, so revoking it also signs every browser out, |
| 199 | // as `ermite web logout --all` does: web sessions are not tied to the token | 216 | // as `ermite web logout --all` does: web sessions are not tied to the token | ||
| 200 | // that opened them. The revocation holds even if signing out fails, which is | 217 | // that opened them. The revocation holds even if signing out fails, which is | ||
| 201 | // then reported as an error. | 218 | // then reported as an error. | ||
M internal/usecase/tokens_test.go +117 -0
| @@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||
| 177 | 177 | }) | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | + | adminOn := func(prefixes []string) domain.Principal { | |
| 181 | + | return domain.TokenPrincipal(domain.Token{ | |
| 182 | + | ID: "creator", | |
| 183 | + | Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), | |
| 184 | + | BranchPrefixes: prefixes, | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | ||
| 180 | 188 | tests := []struct { | |
| 181 | 189 | name string | |
| 182 | 190 | principal domain.Principal | |
| @@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||
| 211 | 219 | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil}, | |
| 212 | 220 | {"root: everything", domain.Root(), | |
| 213 | 221 | ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil}, | |
| 222 | + | {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}), | |
| 223 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil}, | |
| 224 | + | {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}), | |
| 225 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil}, | |
| 226 | + | {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}), | |
| 227 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil}, | |
| 228 | + | {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}), | |
| 229 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden}, | |
| 230 | + | {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}), | |
| 231 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden}, | |
| 232 | + | {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}), | |
| 233 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden}, | |
| 234 | + | {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}), | |
| 235 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden}, | |
| 236 | + | {"unlimited creator may limit branches", admin(nil, time.Time{}), | |
| 237 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil}, | |
| 214 | 238 | } | |
| 215 | 239 | for _, tt := range tests { | |
| 216 | 240 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -283,6 +307,7 @@ func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) { | |||
| 283 | 307 | "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true}, | |
| 284 | 308 | "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false}, | |
| 285 | 309 | "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false}, | |
| 310 | + | "branch prefixes": {domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}}, domain.Root(), false}, | |
| 286 | 311 | } { | |
| 287 | 312 | t.Run(name, func(t *testing.T) { | |
| 288 | 313 | t.Parallel() | |
| @@ -551,3 +576,95 @@ func TestAuthenticate(t *testing.T) { | |||
| 551 | 576 | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 552 | 577 | }) | |
| 553 | 578 | } | |
| 579 | + | ||
| 580 | + | func TestTokensCreateBranchPrefixes(t *testing.T) { | |
| 581 | + | t.Parallel() | |
| 582 | + | ctx := context.Background() | |
| 583 | + | ||
| 584 | + | t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) { | |
| 585 | + | t.Parallel() | |
| 586 | + | f := newTokenFixture(t) | |
| 587 | + | f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool { | |
| 588 | + | return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes) | |
| 589 | + | })).Return(nil) | |
| 590 | + | got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ | |
| 591 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"}, | |
| 592 | + | }) | |
| 593 | + | require.NoError(t, err) | |
| 594 | + | assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes) | |
| 595 | + | }) | |
| 596 | + | ||
| 597 | + | t.Run("no prefixes stays unlimited", func(t *testing.T) { | |
| 598 | + | t.Parallel() | |
| 599 | + | f := newTokenFixture(t) | |
| 600 | + | f.store.EXPECT().Create(ctx, mock.Anything).Return(nil) | |
| 601 | + | got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 602 | + | require.NoError(t, err) | |
| 603 | + | assert.Empty(t, got.Token.BranchPrefixes) | |
| 604 | + | }) | |
| 605 | + | ||
| 606 | + | t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) { | |
| 607 | + | t.Parallel() | |
| 608 | + | nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"} | |
| 609 | + | for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} { | |
| 610 | + | f := newTokenFixture(t) // the store expects no Create | |
| 611 | + | _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ | |
| 612 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad, | |
| 613 | + | }) | |
| 614 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |
| 615 | + | } | |
| 616 | + | }) | |
| 617 | + | ||
| 618 | + | t.Run("a corrupt empty creator prefix does not cover every child prefix", func(t *testing.T) { | |
| 619 | + | t.Parallel() | |
| 620 | + | f := newTokenFixture(t) // the store expects no Create | |
| 621 | + | corrupt := domain.TokenPrincipal(domain.Token{ | |
| 622 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), BranchPrefixes: []string{""}, | |
| 623 | + | }) | |
| 624 | + | _, err := f.tokens.Create(ctx, corrupt, ports.CreateTokenParams{ | |
| 625 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"main/"}, | |
| 626 | + | }) | |
| 627 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 628 | + | }) | |
| 629 | + | ||
| 630 | + | t.Run("revoking is bounded by the prefixes of a limited token", func(t *testing.T) { | |
| 631 | + | t.Parallel() | |
| 632 | + | ctx := context.Background() | |
| 633 | + | creator := domain.TokenPrincipal(domain.Token{ | |
| 634 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}, | |
| 635 | + | }) | |
| 636 | + | inside := domain.Token{ID: "in", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}} | |
| 637 | + | wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}} | |
| 638 | + | sibling := domain.Token{ID: "sib", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}} | |
| 639 | + | free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)} | |
| 640 | + | ||
| 641 | + | f := newTokenFixture(t) | |
| 642 | + | for _, tok := range []domain.Token{wide, sibling, free} { | |
| 643 | + | f.store.EXPECT().Get(ctx, tok.ID).Return(tok, nil).Once() | |
| 644 | + | } | |
| 645 | + | // Out of reach: not found, like an unknown id, and nothing is revoked. | |
| 646 | + | for _, id := range []string{"wide", "sib", "free"} { | |
| 647 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, creator, id), domain.ErrNotFound, id) | |
| 648 | + | } | |
| 649 | + | // Within its prefixes it revokes, and no web session is signed out. | |
| 650 | + | f.store.EXPECT().Get(ctx, "in").Return(inside, nil).Once() | |
| 651 | + | f.store.EXPECT().Revoke(ctx, "in").Return(nil).Once() | |
| 652 | + | require.NoError(t, f.tokens.Revoke(ctx, creator, "in")) | |
| 653 | + | }) | |
| 654 | + | ||
| 655 | + | t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) { | |
| 656 | + | t.Parallel() | |
| 657 | + | f := newTokenFixture(t) | |
| 658 | + | mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}} | |
| 659 | + | wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}} | |
| 660 | + | free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)} | |
| 661 | + | f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil) | |
| 662 | + | p := domain.TokenPrincipal(domain.Token{ | |
| 663 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}, | |
| 664 | + | }) | |
| 665 | + | got, err := f.tokens.List(ctx, p) | |
| 666 | + | require.NoError(t, err) | |
| 667 | + | require.Len(t, got, 1) | |
| 668 | + | assert.Equal(t, "mine", got[0].ID) | |
| 669 | + | }) | |
| 670 | + | } | |
| @@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||||
| 177 | }) | 177 | }) | ||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | + | adminOn := func(prefixes []string) domain.Principal { | |||
| 181 | + | return domain.TokenPrincipal(domain.Token{ | |||
| 182 | + | ID: "creator", | |||
| 183 | + | Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), | |||
| 184 | + | BranchPrefixes: prefixes, | |||
| 185 | + | }) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 180 | tests := []struct { | 188 | tests := []struct { | ||
| 181 | name string | 189 | name string | ||
| 182 | principal domain.Principal | 190 | principal domain.Principal | ||
| @@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||||
| 211 | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil}, | 219 | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil}, | ||
| 212 | {"root: everything", domain.Root(), | 220 | {"root: everything", domain.Root(), | ||
| 213 | ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil}, | 221 | ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil}, | ||
| 222 | + | {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}), | |||
| 223 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil}, | |||
| 224 | + | {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}), | |||
| 225 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil}, | |||
| 226 | + | {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}), | |||
| 227 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil}, | |||
| 228 | + | {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}), | |||
| 229 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden}, | |||
| 230 | + | {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}), | |||
| 231 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden}, | |||
| 232 | + | {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}), | |||
| 233 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden}, | |||
| 234 | + | {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}), | |||
| 235 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden}, | |||
| 236 | + | {"unlimited creator may limit branches", admin(nil, time.Time{}), | |||
| 237 | + | ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil}, | |||
| 214 | } | 238 | } | ||
| 215 | for _, tt := range tests { | 239 | for _, tt := range tests { | ||
| 216 | t.Run(tt.name, func(t *testing.T) { | 240 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -283,6 +307,7 @@ func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) { | |||||
| 283 | "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true}, | 307 | "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true}, | ||
| 284 | "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false}, | 308 | "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false}, | ||
| 285 | "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false}, | 309 | "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false}, | ||
| 310 | + | "branch prefixes": {domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}}, domain.Root(), false}, | |||
| 286 | } { | 311 | } { | ||
| 287 | t.Run(name, func(t *testing.T) { | 312 | t.Run(name, func(t *testing.T) { | ||
| 288 | t.Parallel() | 313 | t.Parallel() | ||
| @@ -551,3 +576,95 @@ func TestAuthenticate(t *testing.T) { | |||||
| 551 | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | 576 | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | ||
| 552 | }) | 577 | }) | ||
| 553 | } | 578 | } | ||
| 579 | + | ||||
| 580 | + | func TestTokensCreateBranchPrefixes(t *testing.T) { | |||
| 581 | + | t.Parallel() | |||
| 582 | + | ctx := context.Background() | |||
| 583 | + | ||||
| 584 | + | t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) { | |||
| 585 | + | t.Parallel() | |||
| 586 | + | f := newTokenFixture(t) | |||
| 587 | + | f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool { | |||
| 588 | + | return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes) | |||
| 589 | + | })).Return(nil) | |||
| 590 | + | got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ | |||
| 591 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"}, | |||
| 592 | + | }) | |||
| 593 | + | require.NoError(t, err) | |||
| 594 | + | assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes) | |||
| 595 | + | }) | |||
| 596 | + | ||||
| 597 | + | t.Run("no prefixes stays unlimited", func(t *testing.T) { | |||
| 598 | + | t.Parallel() | |||
| 599 | + | f := newTokenFixture(t) | |||
| 600 | + | f.store.EXPECT().Create(ctx, mock.Anything).Return(nil) | |||
| 601 | + | got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)}) | |||
| 602 | + | require.NoError(t, err) | |||
| 603 | + | assert.Empty(t, got.Token.BranchPrefixes) | |||
| 604 | + | }) | |||
| 605 | + | ||||
| 606 | + | t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) { | |||
| 607 | + | t.Parallel() | |||
| 608 | + | nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"} | |||
| 609 | + | for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} { | |||
| 610 | + | f := newTokenFixture(t) // the store expects no Create | |||
| 611 | + | _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ | |||
| 612 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad, | |||
| 613 | + | }) | |||
| 614 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |||
| 615 | + | } | |||
| 616 | + | }) | |||
| 617 | + | ||||
| 618 | + | t.Run("a corrupt empty creator prefix does not cover every child prefix", func(t *testing.T) { | |||
| 619 | + | t.Parallel() | |||
| 620 | + | f := newTokenFixture(t) // the store expects no Create | |||
| 621 | + | corrupt := domain.TokenPrincipal(domain.Token{ | |||
| 622 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), BranchPrefixes: []string{""}, | |||
| 623 | + | }) | |||
| 624 | + | _, err := f.tokens.Create(ctx, corrupt, ports.CreateTokenParams{ | |||
| 625 | + | Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"main/"}, | |||
| 626 | + | }) | |||
| 627 | + | assert.ErrorIs(t, err, domain.ErrForbidden) | |||
| 628 | + | }) | |||
| 629 | + | ||||
| 630 | + | t.Run("revoking is bounded by the prefixes of a limited token", func(t *testing.T) { | |||
| 631 | + | t.Parallel() | |||
| 632 | + | ctx := context.Background() | |||
| 633 | + | creator := domain.TokenPrincipal(domain.Token{ | |||
| 634 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}, | |||
| 635 | + | }) | |||
| 636 | + | inside := domain.Token{ID: "in", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}} | |||
| 637 | + | wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}} | |||
| 638 | + | sibling := domain.Token{ID: "sib", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}} | |||
| 639 | + | free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)} | |||
| 640 | + | ||||
| 641 | + | f := newTokenFixture(t) | |||
| 642 | + | for _, tok := range []domain.Token{wide, sibling, free} { | |||
| 643 | + | f.store.EXPECT().Get(ctx, tok.ID).Return(tok, nil).Once() | |||
| 644 | + | } | |||
| 645 | + | // Out of reach: not found, like an unknown id, and nothing is revoked. | |||
| 646 | + | for _, id := range []string{"wide", "sib", "free"} { | |||
| 647 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, creator, id), domain.ErrNotFound, id) | |||
| 648 | + | } | |||
| 649 | + | // Within its prefixes it revokes, and no web session is signed out. | |||
| 650 | + | f.store.EXPECT().Get(ctx, "in").Return(inside, nil).Once() | |||
| 651 | + | f.store.EXPECT().Revoke(ctx, "in").Return(nil).Once() | |||
| 652 | + | require.NoError(t, f.tokens.Revoke(ctx, creator, "in")) | |||
| 653 | + | }) | |||
| 654 | + | ||||
| 655 | + | t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) { | |||
| 656 | + | t.Parallel() | |||
| 657 | + | f := newTokenFixture(t) | |||
| 658 | + | mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}} | |||
| 659 | + | wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}} | |||
| 660 | + | free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)} | |||
| 661 | + | f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil) | |||
| 662 | + | p := domain.TokenPrincipal(domain.Token{ | |||
| 663 | + | ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}, | |||
| 664 | + | }) | |||
| 665 | + | got, err := f.tokens.List(ctx, p) | |||
| 666 | + | require.NoError(t, err) | |||
| 667 | + | require.Len(t, got, 1) | |||
| 668 | + | assert.Equal(t, "mine", got[0].ID) | |||
| 669 | + | }) | |||
| 670 | + | } | |||
M proto/ermite/storage/repo.proto +3 -0
| @@ -53,4 +53,7 @@ message Token { | |||
| 53 | 53 | google.protobuf.Timestamp created_at = 7; | |
| 54 | 54 | // Unset until the token is first used. | |
| 55 | 55 | google.protobuf.Timestamp last_used_at = 8; | |
| 56 | + | // Branch prefixes, each ending with "/", that the token may write; empty | |
| 57 | + | // means every branch. Added in storage schema 2. | |
| 58 | + | repeated string branch_prefixes = 9; | |
| 56 | 59 | } | |
| @@ -53,4 +53,7 @@ message Token { | |||||
| 53 | google.protobuf.Timestamp created_at = 7; | 53 | google.protobuf.Timestamp created_at = 7; | ||
| 54 | // Unset until the token is first used. | 54 | // Unset until the token is first used. | ||
| 55 | google.protobuf.Timestamp last_used_at = 8; | 55 | google.protobuf.Timestamp last_used_at = 8; | ||
| 56 | + | // Branch prefixes, each ending with "/", that the token may write; empty | |||
| 57 | + | // means every branch. Added in storage schema 2. | |||
| 58 | + | repeated string branch_prefixes = 9; | |||
| 56 | } | 59 | } | ||
M skills/ermite/CLI.md +5 -3
| @@ -20,7 +20,7 @@ Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created | |||
| 20 | 20 | ||
| 21 | 21 | | Command | Scope | Notes | | |
| 22 | 22 | |---|---|---| | |
| 23 | - | | `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope | | |
| 23 | + | | `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--branch-prefix P] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope | | |
| 24 | 24 | | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed | | |
| 25 | 25 | | `token revoke <id>` | `token:admin` | | | |
| 26 | 26 | ||
| @@ -44,7 +44,9 @@ Scopes: | |||
| 44 | 44 | | `token:admin` | create, list and revoke tokens | | |
| 45 | 45 | | `ci:write`, `secrets:write`, `runner:register` | reserved for CI | | |
| 46 | 46 | ||
| 47 | - | A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `expires_at`, `created_at`, `last_used_at`. | |
| 47 | + | A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `branch_prefixes`, `expires_at`, `created_at`, `last_used_at`. | |
| 48 | + | ||
| 49 | + | `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags. | |
| 48 | 50 | ||
| 49 | 51 | ## Merge requests | |
| 50 | 52 | ||
| @@ -80,5 +82,5 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, ` | |||
| 80 | 82 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | |
| 81 | 83 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | |
| 82 | 84 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | |
| 83 | - | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret | | |
| 85 | + | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | | |
| 84 | 86 | | `web logout --all` | signs every browser out | | |
| @@ -20,7 +20,7 @@ Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created | |||||
| 20 | 20 | ||||
| 21 | | Command | Scope | Notes | | 21 | | Command | Scope | Notes | | ||
| 22 | |---|---|---| | 22 | |---|---|---| | ||
| 23 | - | | `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope | | 23 | + | | `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--branch-prefix P] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope | |
| 24 | | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed | | 24 | | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed | | ||
| 25 | | `token revoke <id>` | `token:admin` | | | 25 | | `token revoke <id>` | `token:admin` | | | ||
| 26 | 26 | ||||
| @@ -44,7 +44,9 @@ Scopes: | |||||
| 44 | | `token:admin` | create, list and revoke tokens | | 44 | | `token:admin` | create, list and revoke tokens | | ||
| 45 | | `ci:write`, `secrets:write`, `runner:register` | reserved for CI | | 45 | | `ci:write`, `secrets:write`, `runner:register` | reserved for CI | | ||
| 46 | 46 | ||||
| 47 | - | A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `expires_at`, `created_at`, `last_used_at`. | 47 | + | A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `branch_prefixes`, `expires_at`, `created_at`, `last_used_at`. |
| 48 | + | ||||
| 49 | + | `--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags. | |||
| 48 | 50 | ||||
| 49 | ## Merge requests | 51 | ## Merge requests | ||
| 50 | 52 | ||||
| @@ -80,5 +82,5 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, ` | |||||
| 80 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | 82 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | ||
| 81 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | 83 | | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it | | ||
| 82 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | 84 | | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` | | ||
| 83 | - | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret | | 85 | + | | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret | |
| 84 | | `web logout --all` | signs every browser out | | 86 | | `web logout --all` | signs every browser out | | ||