Skip to content

Commit

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.

syntaqxcommitted Parent6c97c24Browse files
2 files+92−340/2 viewed
+63−25
4444 Runners,
4545 Models,
4646 /// 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:*`.
4849 Artifacts,
4950 }
5051
129130 }
130131 }
131132
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.
138139 pub fn offered(self) -> bool {
139− !matches!(self, Resource::Artifacts)
140+ let _ = self;
141+ true
140142 }
141143 }
142144
12191221 // The AI Gateway. Sending a request to a model needs `models:write`,
12201222 // checked by the model proxy at models.g1t.sh, not here.
12211223 ("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),
12221246 ];
12231247
12241248 /// Operations any token may use: saying who it is.
14901514 }
14911515
14921516 #[test]
1493− fn artifacts_scopes_exist_but_are_not_offered_yet() {
1517+ fn artifacts_are_offered_read_by_presets_and_shared_only_with_admin() {
14941518 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
14951519 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());
15081530 }
1531+ assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead));
1532+ assert!(everything().contains(&Scope::ArtifactsAdmin));
15091533 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
15101534 assert!(Scope::ArtifactsAdmin.dangerous());
1535+ assert!(!Scope::ArtifactsWrite.dangerous());
15111536 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);
15151553 }
15161554
15171555 #[test]
17741812 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
17751813 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
17761814 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);
17781816 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
17791817 .lines()
17801818 .filter_map(|line| {
+29−9
7979 { scope: "runners:admin", description: "Register and remove self-hosted runners, change their groups and settings" },
8080 { scope: "models:read", description: "See the workspace's AI Gateway requests: their models, tokens, cost and status" },
8181 { 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" },
8285 ] as const;
8386
8487 /**
8689 * `Resource::offered`). They parse in Rust and are typed here, but no
8790 * preset, full access, OAuth request or token form hands them out, and no
8891 * 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.
9093 */
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 }[] = [
9595 ] as const;
9696
97−export type Scope = (typeof SCOPES)[number]["scope"] | (typeof UPCOMING_SCOPES)[number]["scope"];
97+export type Scope = (typeof SCOPES)[number]["scope"];
9898
9999 /** Where a resource sits on the token form; only a person's token may hold account ones. */
100100 export type ResourceGroup = "repository" | "workspace" | "account";
122122 { resource: "secrets", label: "Secrets and variables", group: "repository" },
123123 { resource: "runners", label: "Self-hosted runners", group: "workspace" },
124124 { resource: "models", label: "AI Gateway", group: "workspace" },
125+ { resource: "artifacts", label: "Artifacts", group: "workspace" },
125126 ];
126127
127128 /** Resources not offered yet, as `UPCOMING_SCOPES`: settings never show them. */
128129 export const UPCOMING_RESOURCES: { resource: ScopeResource; label: string; group: ResourceGroup }[] = [
129− { resource: "artifacts", label: "Artifacts", group: "workspace" },
130130 ];
131131
132132 const LEVEL_ORDER: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, delete: 3, admin: 4 };
218218 /** Starting points for choosing scopes. `*` is full access. */
219219 export const PRESET_SCOPES = {
220220 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",
222222 ] as const,
223223 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",
225225 ] as const,
226226 ci: [
227227 "repo:read", "code:read", "code:write", "packages:read", "packages:write", "workflows:read", "workflows:write", "checks:read", "checks:write", "deployments:read", "deployments:write",
599599 // The AI Gateway. Sending a request to a model needs `models:write`,
600600 // checked by the model proxy at models.g1t.sh.
601601 ["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"],
602621 ] as const;
603622
604623 /**
632651 { id: "billing", label: "Billing", scopes: ["billing:read", "billing:write"] },
633652 { id: "runners", label: "Runners", scopes: ["runners:read"] },
634653 { id: "models", label: "AI Gateway", scopes: ["models:read", "models:write"] },
654+ { id: "artifacts", label: "Artifacts", scopes: ["artifacts:read", "artifacts:write"] },
635655 ];
636656
637657 /** The admin and delete scopes, shown under "Dangerous" behind a warning. */