Tokens can be given artifacts:read, artifacts:write and artifacts:admin: the read-only and agent presets read artifacts, the token form and OAuth offer all three, and each artifact operation names the scope it needs, in Rust and in the TypeScript mirror.
2 files+92−340/2 viewed
| 44 | 44 | Runners, | |
| 45 | 45 | Models, | |
| 46 | 46 | /// Artifacts mode's docs, slides, designs and dashboards (folios in | |
| 47 | − | /// code). Not offered yet: see [`Resource::offered`]. | |
| 47 | + | /// code): the `artifact` MCP tool and `/workspaces/{ws}/artifacts`. | |
| 48 | + | /// Not workflow runs' artifacts, which are `workflows:*`. | |
| 48 | 49 | Artifacts, | |
| 49 | 50 | } | |
| 50 | 51 | ||
| ⋯ | |||
| 129 | 130 | } | |
| 130 | 131 | } | |
| 131 | 132 | ||
| 132 | − | /// Whether tokens are offered it yet. A resource that is not is in the | |
| 133 | − | /// table (so its scopes parse, and the TypeScript mirror lists it under | |
| 134 | − | /// `UPCOMING_RESOURCES`) but nothing hands it out: presets, full | |
| 135 | − | /// access, OAuth and the token form leave it out, and no operation | |
| 136 | − | /// needs it. Artifacts is offered once its API ships (Phase 3 of | |
| 137 | − | /// docs/ARTIFACTS_MODE.md). | |
| 133 | + | /// Whether tokens are offered it yet. A resource being built can be in | |
| 134 | + | /// the table before its API ships (so its scopes parse, and the | |
| 135 | + | /// TypeScript mirror lists it under `UPCOMING_RESOURCES`) while nothing | |
| 136 | + | /// hands it out: presets, full access, OAuth and the token form leave | |
| 137 | + | /// it out, and no operation needs it. Every resource is offered now; | |
| 138 | + | /// Artifacts was the last, until Phase 3 of docs/ARTIFACTS_MODE.md. | |
| 138 | 139 | pub fn offered(self) -> bool { | |
| 139 | − | !matches!(self, Resource::Artifacts) | |
| 140 | + | let _ = self; | |
| 141 | + | true | |
| 140 | 142 | } | |
| 141 | 143 | } | |
| 142 | 144 | ||
| ⋯ | |||
| 1219 | 1221 | // The AI Gateway. Sending a request to a model needs `models:write`, | |
| 1220 | 1222 | // checked by the model proxy at models.g1t.sh, not here. | |
| 1221 | 1223 | ("list_gateway_requests", Scope::ModelsRead), | |
| 1224 | + | // Artifacts mode's docs, slides, designs and dashboards (the `artifact` | |
| 1225 | + | // MCP tool). Reading takes artifacts:read, making and changing them | |
| 1226 | + | // artifacts:write, and sharing them or deleting them for good | |
| 1227 | + | // artifacts:admin. The docs service then checks the person's own role | |
| 1228 | + | // on each one. | |
| 1229 | + | ("list_workspace_artifacts", Scope::ArtifactsRead), | |
| 1230 | + | ("search_workspace_artifacts", Scope::ArtifactsRead), | |
| 1231 | + | ("get_workspace_artifact", Scope::ArtifactsRead), | |
| 1232 | + | ("get_workspace_artifact_content", Scope::ArtifactsRead), | |
| 1233 | + | ("list_workspace_artifact_versions", Scope::ArtifactsRead), | |
| 1234 | + | ("get_workspace_artifact_access", Scope::ArtifactsRead), | |
| 1235 | + | ("list_workspace_artifact_templates", Scope::ArtifactsRead), | |
| 1236 | + | ("list_workspace_artifact_spaces", Scope::ArtifactsRead), | |
| 1237 | + | ("query_workspace_dataset", Scope::ArtifactsRead), | |
| 1238 | + | ("create_workspace_artifact", Scope::ArtifactsWrite), | |
| 1239 | + | ("update_workspace_artifact", Scope::ArtifactsWrite), | |
| 1240 | + | ("edit_workspace_artifact", Scope::ArtifactsWrite), | |
| 1241 | + | ("trash_workspace_artifact", Scope::ArtifactsWrite), | |
| 1242 | + | ("restore_workspace_artifact", Scope::ArtifactsWrite), | |
| 1243 | + | ("restore_workspace_artifact_version", Scope::ArtifactsWrite), | |
| 1244 | + | ("set_workspace_artifact_access", Scope::ArtifactsAdmin), | |
| 1245 | + | ("purge_workspace_artifact", Scope::ArtifactsAdmin), | |
| 1222 | 1246 | ]; | |
| 1223 | 1247 | ||
| 1224 | 1248 | /// Operations any token may use: saying who it is. | |
| ⋯ | |||
| 1490 | 1514 | } | |
| 1491 | 1515 | ||
| 1492 | 1516 | #[test] | |
| 1493 | − | fn artifacts_scopes_exist_but_are_not_offered_yet() { | |
| 1517 | + | fn artifacts_are_offered_read_by_presets_and_shared_only_with_admin() { | |
| 1494 | 1518 | for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] { | |
| 1495 | 1519 | assert_eq!(scope.resource(), Resource::Artifacts); | |
| 1496 | − | assert!(!scope.offered()); | |
| 1497 | − | assert_eq!(Scope::parse(scope.as_str()), Some(scope)); | |
| 1498 | − | // Nothing hands it out: not presets, full access, OAuth or the form. | |
| 1499 | − | for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] { | |
| 1500 | − | assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str()); | |
| 1501 | − | } | |
| 1502 | − | assert!(!everything().contains(&scope)); | |
| 1503 | − | assert!(!offered_scopes().contains(&scope)); | |
| 1504 | − | assert!(!oauth_default().contains(&scope)); | |
| 1505 | − | assert!(parse_scopes(scope.as_str()).is_empty()); | |
| 1506 | − | // And no operation needs it yet. | |
| 1507 | − | assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope)); | |
| 1520 | + | assert!(scope.offered()); | |
| 1521 | + | assert!(offered_scopes().contains(&scope)); | |
| 1522 | + | assert_eq!(parse_scopes(scope.as_str()), vec![scope]); | |
| 1523 | + | assert!(OPERATIONS.iter().any(|(_, needed)| *needed == scope), "{scope:?} gates nothing"); | |
| 1524 | + | } | |
| 1525 | + | // Reading them is a read like any other; changing them is chosen. | |
| 1526 | + | for preset in [Preset::ReadOnly, Preset::Agent] { | |
| 1527 | + | let scopes = preset.scopes().unwrap(); | |
| 1528 | + | assert!(scopes.contains(&Scope::ArtifactsRead), "{}", preset.as_str()); | |
| 1529 | + | assert!(!scopes.contains(&Scope::ArtifactsWrite) && !scopes.contains(&Scope::ArtifactsAdmin), "{}", preset.as_str()); | |
| 1508 | 1530 | } | |
| 1531 | + | assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead)); | |
| 1532 | + | assert!(everything().contains(&Scope::ArtifactsAdmin)); | |
| 1509 | 1533 | assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite)); | |
| 1510 | 1534 | assert!(Scope::ArtifactsAdmin.dangerous()); | |
| 1535 | + | assert!(!Scope::ArtifactsWrite.dangerous()); | |
| 1511 | 1536 | assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace); | |
| 1512 | − | let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]); | |
| 1513 | − | assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned())); | |
| 1514 | − | assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3); | |
| 1537 | + | let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "write".to_owned())]); | |
| 1538 | + | assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ArtifactsWrite])); | |
| 1539 | + | assert_eq!(offered_scopes().len(), Scope::ALL.len()); | |
| 1540 | + | // Sharing and deleting for good need admin; editing needs write. | |
| 1541 | + | assert_eq!(scope_for("set_workspace_artifact_access"), Some(Scope::ArtifactsAdmin)); | |
| 1542 | + | assert_eq!(scope_for("purge_workspace_artifact"), Some(Scope::ArtifactsAdmin)); | |
| 1543 | + | assert_eq!(scope_for("edit_workspace_artifact"), Some(Scope::ArtifactsWrite)); | |
| 1544 | + | assert_eq!(scope_for("get_workspace_artifact_content"), Some(Scope::ArtifactsRead)); | |
| 1545 | + | let writer = token(&[Scope::ArtifactsWrite]); | |
| 1546 | + | assert!(decide(&writer, "edit_workspace_artifact", &json!({})).allowed); | |
| 1547 | + | assert!(decide(&writer, "list_workspace_artifacts", &json!({})).allowed, "write includes read"); | |
| 1548 | + | assert!(decide(&writer, "set_workspace_artifact_access", &json!({})).reason.unwrap().contains("artifacts:admin")); | |
| 1549 | + | // Workflow runs' artifacts are another thing, with their own scope. | |
| 1550 | + | let reader = token(&[Scope::ArtifactsRead]); | |
| 1551 | + | assert!(!decide(&reader, "list_artifacts", &json!({ "repo": "acme/web" })).allowed); | |
| 1552 | + | assert!(!decide(&token(&[Scope::WorkflowsRead]), "list_workspace_artifacts", &json!({})).allowed); | |
| 1515 | 1553 | } | |
| 1516 | 1554 | ||
| 1517 | 1555 | #[test] | |
| ⋯ | |||
| 1774 | 1812 | let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect(); | |
| 1775 | 1813 | let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect(); | |
| 1776 | 1814 | assert_eq!(names("export const SCOPES = ["), offered); | |
| 1777 | − | assert_eq!(names("export const UPCOMING_SCOPES = ["), upcoming); | |
| 1815 | + | assert_eq!(names("export const UPCOMING_SCOPES"), upcoming); | |
| 1778 | 1816 | let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [") | |
| 1779 | 1817 | .lines() | |
| 1780 | 1818 | .filter_map(|line| { | |
| 79 | 79 | { scope: "runners:admin", description: "Register and remove self-hosted runners, change their groups and settings" }, | |
| 80 | 80 | { scope: "models:read", description: "See the workspace's AI Gateway requests: their models, tokens, cost and status" }, | |
| 81 | 81 | { scope: "models:write", description: "Send model requests through the AI Gateway, which uses the workspace's AI credit" }, | |
| 82 | + | { scope: "artifacts:read", description: "List, read and search artifacts you can see, their versions, and the numbers their dashboards show" }, | |
| 83 | + | { scope: "artifacts:write", description: "Create, rename, move, edit, trash and restore artifacts, and propose changes to them" }, | |
| 84 | + | { scope: "artifacts:admin", description: "Share artifacts, change who can open them, and delete them for good" }, | |
| 82 | 85 | ] as const; | |
| 83 | 86 | ||
| 84 | 87 | /** | |
| ⋯ | |||
| 86 | 89 | * `Resource::offered`). They parse in Rust and are typed here, but no | |
| 87 | 90 | * preset, full access, OAuth request or token form hands them out, and no | |
| 88 | 91 | * operation needs them. When one ships, its rows move to the end of | |
| 89 | − | * `SCOPES` and `SCOPE_RESOURCES`. | |
| 92 | + | * `SCOPES` and `SCOPE_RESOURCES`. Empty now: Artifacts shipped last. | |
| 90 | 93 | */ | |
| 91 | − | export const UPCOMING_SCOPES = [ | |
| 92 | − | { scope: "artifacts:read", description: "List, read and search artifacts you can see, their versions, and the numbers their dashboards show" }, | |
| 93 | − | { scope: "artifacts:write", description: "Create, rename, move, edit, trash and restore artifacts, and propose changes to them" }, | |
| 94 | − | { scope: "artifacts:admin", description: "Share artifacts, change who can open them, and delete them for good" }, | |
| 94 | + | export const UPCOMING_SCOPES: readonly { scope: Scope; description: string }[] = [ | |
| 95 | 95 | ] as const; | |
| 96 | 96 | ||
| 97 | − | export type Scope = (typeof SCOPES)[number]["scope"] | (typeof UPCOMING_SCOPES)[number]["scope"]; | |
| 97 | + | export type Scope = (typeof SCOPES)[number]["scope"]; | |
| 98 | 98 | ||
| 99 | 99 | /** Where a resource sits on the token form; only a person's token may hold account ones. */ | |
| 100 | 100 | export type ResourceGroup = "repository" | "workspace" | "account"; | |
| ⋯ | |||
| 122 | 122 | { resource: "secrets", label: "Secrets and variables", group: "repository" }, | |
| 123 | 123 | { resource: "runners", label: "Self-hosted runners", group: "workspace" }, | |
| 124 | 124 | { resource: "models", label: "AI Gateway", group: "workspace" }, | |
| 125 | + | { resource: "artifacts", label: "Artifacts", group: "workspace" }, | |
| 125 | 126 | ]; | |
| 126 | 127 | ||
| 127 | 128 | /** Resources not offered yet, as `UPCOMING_SCOPES`: settings never show them. */ | |
| 128 | 129 | export const UPCOMING_RESOURCES: { resource: ScopeResource; label: string; group: ResourceGroup }[] = [ | |
| 129 | − | { resource: "artifacts", label: "Artifacts", group: "workspace" }, | |
| 130 | 130 | ]; | |
| 131 | 131 | ||
| 132 | 132 | const LEVEL_ORDER: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, delete: 3, admin: 4 }; | |
| ⋯ | |||
| 218 | 218 | /** Starting points for choosing scopes. `*` is full access. */ | |
| 219 | 219 | export const PRESET_SCOPES = { | |
| 220 | 220 | read_only: [ | |
| 221 | − | "repo:read", "code:read", "security:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "checks:read", "deployments:read", "memory:read", "account:read", "notifications:read", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "runners:read", "models:read", | |
| 221 | + | "repo:read", "code:read", "security:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "checks:read", "deployments:read", "memory:read", "account:read", "notifications:read", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "runners:read", "models:read", "artifacts:read", | |
| 222 | 222 | ] as const, | |
| 223 | 223 | agent: [ | |
| 224 | − | "repo:read", "code:read", "code:write", "security:read", "packages:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "agents:run", "workflows:read", "checks:read", "deployments:read", "memory:read", "memory:write", "account:read", "notifications:read", "notifications:write", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "models:read", | |
| 224 | + | "repo:read", "code:read", "code:write", "security:read", "packages:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write", "agents:run", "workflows:read", "checks:read", "deployments:read", "memory:read", "memory:write", "account:read", "notifications:read", "notifications:write", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "models:read", "artifacts:read", | |
| 225 | 225 | ] as const, | |
| 226 | 226 | ci: [ | |
| 227 | 227 | "repo:read", "code:read", "code:write", "packages:read", "packages:write", "workflows:read", "workflows:write", "checks:read", "checks:write", "deployments:read", "deployments:write", | |
| ⋯ | |||
| 599 | 599 | // The AI Gateway. Sending a request to a model needs `models:write`, | |
| 600 | 600 | // checked by the model proxy at models.g1t.sh. | |
| 601 | 601 | ["list_gateway_requests", "models:read"], | |
| 602 | + | // Artifacts mode's docs, slides, designs and dashboards (the `artifact` | |
| 603 | + | // MCP tool). The docs service then checks the person's role on each. | |
| 604 | + | ["list_workspace_artifacts", "artifacts:read"], | |
| 605 | + | ["search_workspace_artifacts", "artifacts:read"], | |
| 606 | + | ["get_workspace_artifact", "artifacts:read"], | |
| 607 | + | ["get_workspace_artifact_content", "artifacts:read"], | |
| 608 | + | ["list_workspace_artifact_versions", "artifacts:read"], | |
| 609 | + | ["get_workspace_artifact_access", "artifacts:read"], | |
| 610 | + | ["list_workspace_artifact_templates", "artifacts:read"], | |
| 611 | + | ["list_workspace_artifact_spaces", "artifacts:read"], | |
| 612 | + | ["query_workspace_dataset", "artifacts:read"], | |
| 613 | + | ["create_workspace_artifact", "artifacts:write"], | |
| 614 | + | ["update_workspace_artifact", "artifacts:write"], | |
| 615 | + | ["edit_workspace_artifact", "artifacts:write"], | |
| 616 | + | ["trash_workspace_artifact", "artifacts:write"], | |
| 617 | + | ["restore_workspace_artifact", "artifacts:write"], | |
| 618 | + | ["restore_workspace_artifact_version", "artifacts:write"], | |
| 619 | + | ["set_workspace_artifact_access", "artifacts:admin"], | |
| 620 | + | ["purge_workspace_artifact", "artifacts:admin"], | |
| 602 | 621 | ] as const; | |
| 603 | 622 | ||
| 604 | 623 | /** | |
| ⋯ | |||
| 632 | 651 | { id: "billing", label: "Billing", scopes: ["billing:read", "billing:write"] }, | |
| 633 | 652 | { id: "runners", label: "Runners", scopes: ["runners:read"] }, | |
| 634 | 653 | { id: "models", label: "AI Gateway", scopes: ["models:read", "models:write"] }, | |
| 654 | + | { id: "artifacts", label: "Artifacts", scopes: ["artifacts:read", "artifacts:write"] }, | |
| 635 | 655 | ]; | |
| 636 | 656 | ||
| 637 | 657 | /** The admin and delete scopes, shown under "Dangerous" behind a warning. */ | |