diff --git a/.github/workflows/test-and-build-artifacts.yml b/.github/workflows/test-and-build-artifacts.yml index 219f7d4..79a7ed6 100644 --- a/.github/workflows/test-and-build-artifacts.yml +++ b/.github/workflows/test-and-build-artifacts.yml @@ -1,6 +1,20 @@ name: Test And Build Artifacts on: + pull_request: + branches: + - main + paths: + - "*.go" + - "**/*.go" + - "go.mod" + - "go.sum" + - "CHANGELOG.md" + - "broker/**" + - "www/**" + - "testsuite/**" + - ".github/workflows/test-and-build-artifacts.yml" + - ".github/workflows/test.yml" push: branches: - main @@ -15,7 +29,14 @@ on: - "www/**" - "testsuite/**" - ".github/workflows/test-and-build-artifacts.yml" + - ".github/workflows/test.yml" workflow_dispatch: + inputs: + speed_lane: + description: "Skip tests and integration; build and publish only" + required: false + default: false + type: boolean permissions: contents: read @@ -27,6 +48,7 @@ concurrency: jobs: unit: name: Unit Tests (${{ matrix.os }}) + if: (github.ref == 'refs/heads/develop' || github.event_name == 'workflow_dispatch') && !(github.event_name == 'push' && contains(github.event.head_commit.message || '', '[speed-lane]')) && !(github.event_name == 'workflow_dispatch' && github.event.inputs.speed_lane == 'true') runs-on: ${{ matrix.os }} timeout-minutes: 15 permissions: @@ -57,6 +79,7 @@ jobs: name: Build Multiarchitecture Artifacts needs: - unit + if: always() && (github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || needs.unit.result == 'success' || (github.event_name == 'push' && contains(github.event.head_commit.message || '', '[speed-lane]')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.speed_lane == 'true')) runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -74,7 +97,7 @@ jobs: cache-dependency-path: go.sum - name: Resolve Version From Changelog - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) shell: pwsh run: | if (!(Test-Path "CHANGELOG.md")) { @@ -194,6 +217,7 @@ jobs: name: Integration Tests (${{ matrix.os }}) needs: - build + if: (github.ref == 'refs/heads/develop' || github.event_name == 'workflow_dispatch') && !(github.event_name == 'push' && contains(github.event.head_commit.message || '', '[speed-lane]')) && !(github.event_name == 'workflow_dispatch' && github.event.inputs.speed_lane == 'true') runs-on: ${{ matrix.os }} timeout-minutes: 45 permissions: @@ -277,9 +301,10 @@ jobs: publish: name: Publish Release needs: + - build - integration runs-on: ubuntu-24.04 - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && ((github.ref == 'refs/heads/main' && needs.build.result == 'success') || (github.ref == 'refs/heads/develop' && (needs.integration.result == 'success' || ((((github.event_name == 'push' && contains(github.event.head_commit.message || '', '[speed-lane]')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.speed_lane == 'true')) && needs.build.result == 'success'))))) timeout-minutes: 15 permissions: contents: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ccbeac3..d39e5a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,8 @@ name: Test on: pull_request: + branches: + - develop workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdcfcd..fce5085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to `bgit` are documented in this file. This project follows semantic versioning. +## 1.1.0 + +Changed + +- Added broker users, broker admins, teams, team-to-repository grants, and + exact-FQDN TXT discovery for team clone URLs. +- `bgit setup` now seeds the default `core` team, and flat repository flows map + through `core` while still accepting explicit team clone URLs. +- `bgit setup` now starts from configured brokers, with explicit new, update, + manage, and delete paths instead of mixing broker creation and redeploys. +- Broker user creation now uses an invite/accept flow, setup management fields + use selectable roles/users/teams/repos, and invalid roles are rejected. + ## 1.0.1 Changed diff --git a/README.md b/README.md index ca1bec9..9709003 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,15 @@ Set up BucketGit for one or more cloud profiles: bgit setup ``` -`bgit setup` discovers GCP and AWS profiles, lets you choose regions, imports -owner SSH keys, deploys or updates the broker, and writes global configuration to -`~/.bgit/config.yaml`. +`bgit setup` is the interactive broker setup and management tool. It discovers +GCP and AWS profiles, lets you choose regions, creates or updates brokers, +imports owner SSH keys, manages users and teams, and writes global configuration +to `~/.bgit/config.yaml`. -Create a new repository: +Create a broker repository, then attach a local checkout: ```bash +bgit admin repo create --team core demo mkdir demo cd demo bgit init @@ -82,7 +84,15 @@ bgit push Clone an existing broker-backed repository: ```bash -bgit clone https://broker.example.com/team/demo.git ./demo +bgit clone https://broker.example.com/demo.git ./demo +``` + +Flat clone URLs use the broker's default `core` team. The explicit form is also +accepted: + +```bash +bgit clone https://broker.example.com/core/demo.git ./demo +bgit clone https://broker.example.com/core/demo/demo.git ./demo ``` Inside an initialized checkout, normal Git commands also work for fetch and push @@ -93,6 +103,30 @@ git fetch git push ``` +## Custom Domains + +BucketGit can discover brokers from DNS TXT records, so users can clone from a +clean domain instead of a generated Cloud Run or Lambda Function URL. + +For `https://git.example.com/...`, publish records at `_bgit.git.example.com`. +Discovery is exact-FQDN based; BucketGit does not fall back from +`git.example.com` to `example.com`. + +```text +v=bgit1 broker=https://broker.example.com team=t_abcd1234 name=platform +``` + +The `name` is the public path segment users type. The `team` value is the +opaque broker team identifier. With the record above, both forms work: + +```bash +bgit clone https://git.example.com/platform/demo.git ./demo +bgit clone https://git.example.com/platform/demo/demo.git ./demo +``` + +BucketGit skips TXT discovery for direct broker URLs such as Cloud Run and AWS +Lambda Function URLs. + ## Common Commands ```bash @@ -100,9 +134,10 @@ bgit setup bgit setup profile create --provider gcp work bgit setup profile create --provider aws work +bgit admin repo create --team core demo bgit init -bgit init --noninteractive --repo team/demo --profile work.europe-west1 -bgit clone https://broker.example.com/team/demo.git ./demo +bgit init --noninteractive --repo demo --profile work.europe-west1 --team core +bgit clone https://broker.example.com/demo.git ./demo bgit web bgit status @@ -131,9 +166,12 @@ bgit issue view 1 bgit whoami bgit repos mine + +bgit admin repo list +bgit admin repo info ``` -## Setup And Profiles +## Setup And Broker Management Global configuration is stored in `~/.bgit/config.yaml`. Profiles are provider- and region-aware, so the same cloud account can have brokers in @@ -142,7 +180,8 @@ multiple regions. Examples: ```bash -bgit init --noninteractive --repo app --profile work.europe-west1 +bgit admin repo create --team core app +bgit init --noninteractive --repo app --profile work.europe-west1 --team core bgit push --profile work --region europe-west1 ``` @@ -168,6 +207,12 @@ bgit setup profile create --provider aws work GCP setup uses `gcloud` configurations. AWS setup reads AWS config/credentials files and can use the AWS CLI when profile creation is requested. +`bgit setup` also manages configured brokers. From the setup UI you can create, +update, manage, or delete brokers, manage users and teams, and seed the default +`core` team. Repositories are created explicitly with `bgit admin repo create` +or through the setup broker-management UI; `bgit init` attaches a local checkout +to an existing broker repository. + ## Identity BucketGit supports a global name and email in `~/.bgit/config.yaml` and per-repo @@ -195,27 +240,51 @@ an SSH signature. Useful admin commands: ```bash +bgit admin repo list +bgit admin repo info +bgit admin repo create --team platform app + bgit admin keys list bgit admin keys add --user ada --role developer --key ~/.ssh/ada.pub bgit admin keys import-github octocat --role triage bgit admin keys suspend KEY_OR_FINGERPRINT bgit admin keys remove KEY_OR_FINGERPRINT -bgit admin invite-user --broker https://broker.example.com --user ada --role developer team/demo.git +bgit admin invite-user --broker https://broker.example.com --user ada --role developer demo.git bgit admin accept-invite CODE -bgit admin cancel-invite --broker https://broker.example.com --user ada team/demo.git +bgit admin cancel-invite --broker https://broker.example.com --user ada demo.git -bgit admin confirm-ownership-transfer --broker https://broker.example.com team/demo.git +bgit admin invite-broker-user --broker https://broker.example.com --user ada --role user +bgit admin accept-broker-invite CODE +bgit admin cancel-broker-invite --broker https://broker.example.com --user ada + +bgit admin confirm-ownership-transfer --broker https://broker.example.com demo.git bgit admin accept-ownership-transfer CODE -bgit admin cancel-ownership-transfer --broker https://broker.example.com team/demo.git +bgit admin cancel-ownership-transfer --broker https://broker.example.com demo.git bgit admin protect add main bgit admin protect list bgit admin protect remove main + +bgit admin broker-users list +bgit admin broker-users upsert ada --role user --key ~/.ssh/ada.pub +bgit admin broker-users upsert ada --role user --suspended true +bgit admin broker-users delete ada + +bgit admin teams create platform +bgit admin teams delete TEAM_ID +bgit admin teams member add TEAM_ID ada --role developer +bgit admin teams member remove TEAM_ID ada +bgit admin teams repo list +bgit admin teams repo add TEAM_ID developer +bgit admin teams repo remove TEAM_ID ``` A repo can have at most one active pending invite per username. Invite -cancellation is repo-scoped. +cancellation is repo-scoped. Broker logical repository names are flat, such as +`demo.git`; path-shaped clone URLs route through teams. Flat broker clone URLs +use the default `core` team, while `bgit init` prompts for a team or requires +`--team` in noninteractive mode. ## Repository Settings @@ -289,7 +358,7 @@ The web assets are embedded into the `bgit` binary at build time. `bgit init` writes a Git remote like: ```text -git@git.bucketgit.com:team/demo.git +git@git.bucketgit.com:demo.git ``` and configures: diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml index a7257a9..cfbd4ab 100644 --- a/broker/aws/template.yaml +++ b/broker/aws/template.yaml @@ -152,16 +152,25 @@ Resources: const transferRoleArn = process.env.TRANSFER_ROLE_ARN; const brokerVersion = process.env.BROKER_VERSION || "{{BROKER_VERSION}}"; const zero = "0000000000000000000000000000000000000000"; + const coreTeamID = "t_core"; + const coreTeamName = "core"; function response(statusCode, body) { return {statusCode, headers: {"content-type": "application/json"}, body: JSON.stringify(body)}; } function repoID(repo) { + if (repo && repo.logical) return ["logical", repo.team_id || coreTeamID, repo.logical].join(":"); + return [repo.provider || "s3", repo.bucket, repo.prefix].join(":"); + } + function legacyRepoID(repo) { if (repo && repo.logical) return ["logical", repo.logical].join(":"); return [repo.provider || "s3", repo.bucket, repo.prefix].join(":"); } function docID(repo) { return Buffer.from(repoID(repo)).toString("base64url"); } + function legacyDocID(repo) { + return Buffer.from(legacyRepoID(repo)).toString("base64url"); + } function cleanName(value) { return String(value || "repo").toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "repo"; } @@ -175,25 +184,62 @@ Resources: function validateRepo(repo) { if (!repo || (!repo.logical && (!repo.bucket || !repo.prefix))) throw new Error("repo is required"); if (repo.logical) repo.logical = normalizeLogicalRepo(repo.logical); + if (repo.logical && !repo.team_id) repo.team_id = coreTeamID; return repo; } function randomSuffix() { return crypto.randomBytes(5).toString("hex"); } async function loadRepo(repo) { + const hadLogicalNoTeam = !!(repo && repo.logical && !repo.team_id); repo = validateRepo(repo); + if (repo.logical && repo.team_id === coreTeamID) await ensureCoreTeam("repo"); const id = docID(repo); const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); let data = {repo, keys: [], audit: []}; + let entryID = id; if (out.Item) data = JSON.parse(out.Item.data.S || "{}"); + else if (hadLogicalNoTeam || repo.team_id === coreTeamID) { + const legacyID = legacyDocID(repo); + const legacyOut = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: legacyID}}})); + if (legacyOut.Item) { + data = JSON.parse(legacyOut.Item.data.S || "{}"); + entryID = legacyID; + data.repo = {...(data.repo || repo), team_id: coreTeamID}; + } + } data.repo = data.repo || repo; + if (data.repo.logical && !data.repo.team_id) data.repo.team_id = coreTeamID; data.keys = data.keys || []; data.audit = data.audit || []; const owners = await loadOwners(); - for (const owner of owners.data.keys || []) { - if (owner.role === "owner" && !data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(owner.public_key))) data.keys.push(owner); + const ownerKeys = new Set((owners.data.keys || []).filter((owner) => owner.role === "owner").map((owner) => normalizeKey(owner.public_key))); + data.keys = (data.keys || []).filter((key) => !(key.role === "owner" && ownerKeys.has(normalizeKey(key.public_key)) && key.source !== "ownership-transfer")); + return {id: entryID, data}; + } + async function loadExistingRepo(repo) { + const hadLogicalNoTeam = !!(repo && repo.logical && !repo.team_id); + repo = validateRepo(repo); + const id = docID(repo); + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + let data; + let entryID = id; + if (out.Item) data = JSON.parse(out.Item.data.S || "{}"); + else if (hadLogicalNoTeam || repo.team_id === coreTeamID) { + const legacyID = legacyDocID(repo); + const legacyOut = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: legacyID}}})); + if (legacyOut.Item) { + data = JSON.parse(legacyOut.Item.data.S || "{}"); + entryID = legacyID; + data.repo = {...(data.repo || repo), team_id: coreTeamID}; + } } - return {id, data}; + if (!data) return null; + data.repo = data.repo || repo; + if (data.repo.logical && !data.repo.team_id) data.repo.team_id = coreTeamID; + data.keys = data.keys || []; + data.audit = data.audit || []; + return {id: entryID, data}; } async function saveRepo(entry) { await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); @@ -204,16 +250,54 @@ Resources: if (!repo.logical && (!repo.bucket || !repo.prefix)) return; const repoIDValue = repoID(repo); const logical = repo.logical || repo.prefix || repoIDValue; + const users = await loadBrokerUsers(); + const usersByID = new Map((users.data.users || []).map((user) => [user.id, user])); + const memberships = new Map(); + function addMembership(publicKey, user, role, source, suspended) { + if (!publicKey) return; + const fingerprint = keyFingerprint(publicKey); + const existing = memberships.get(fingerprint); + const nextRole = existing ? strongerRole(existing.role, role || "") : role || ""; + memberships.set(fingerprint, {public_key: publicKey, user: user || "", role: nextRole, source: source || "", suspended: !!suspended}); + } for (const key of entry.data.keys || []) { - if (!key.public_key) continue; - const fingerprint = keyFingerprint(key.public_key); + addMembership(key.public_key, key.user, key.role, key.source, key.suspended); + } + for (const grant of entry.data.teams || []) { + const team = await loadTeam(grant.id || grant.team_id); + if (!team) continue; + for (const member of team.data.members || []) { + if (member.suspended) continue; + const user = usersByID.get(member.user_id); + if (!user || user.suspended || user.pending) continue; + const role = weakerRole(normalizeRole(member.role || "read"), normalizeRole(grant.role || "read")); + for (const key of user.keys || []) { + addMembership(key.public_key || key, user.username || member.username || member.user_id, role, "team", false); + } + } + } + for (const membership of memberships.values()) { + const fingerprint = keyFingerprint(membership.public_key); await db.send(new PutItemCommand({TableName: memberTable, Item: { fingerprint: {S: fingerprint}, repo_id: {S: repoIDValue}, - data: {S: JSON.stringify({repo_id: repoIDValue, logical, repo, user: key.user || "", role: key.role || "", source: key.source || "", suspended: !!key.suspended, updated_at: new Date().toISOString()})} + data: {S: JSON.stringify({repo_id: repoIDValue, logical, repo, user: membership.user || "", role: membership.role || "", source: membership.source || "", suspended: !!membership.suspended, updated_at: new Date().toISOString()})} }})); } } + async function syncReposForTeam(teamID) { + let ExclusiveStartKey; + do { + const out = await db.send(new ScanCommand({TableName: table, ExclusiveStartKey})); + for (const item of out.Items || []) { + const id = item.id && item.id.S || ""; + if (id.startsWith("_") || id.startsWith("team:")) continue; + const data = JSON.parse(item.data && item.data.S || "{}"); + if ((data.teams || []).some((grant) => (grant.id || grant.team_id) === teamID)) await syncMembershipIndex({id, data}); + } + ExclusiveStartKey = out.LastEvaluatedKey; + } while (ExclusiveStartKey); + } async function syncAllMembershipIndexes() { let count = 0; let ExclusiveStartKey; @@ -281,6 +365,122 @@ Resources: data.audit = data.audit || []; return {id, data}; } + async function loadBrokerUsers() { + const id = "_users"; + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + const data = out.Item ? JSON.parse(out.Item.data.S || "{}") : {users: []}; + data.users = data.users || []; + const owners = await loadOwners(); + for (const key of owners.data.keys || []) { + const username = key.user || "owner"; + let user = data.users.find((item) => String(item.username || "").toLowerCase() === String(username).toLowerCase()); + if (!user) { + user = {id: "u_" + cleanName(username), username, broker_role: key.role === "admin" ? "admin" : "owner", keys: [], suspended: false}; + data.users.push(user); + } + if (key.public_key && !user.keys.find((k) => normalizeKey(k.public_key || k) === normalizeKey(key.public_key))) user.keys.push({public_key: key.public_key, source: key.source || "owner"}); + } + return {id, data}; + } + async function saveBrokerUsers(entry) { + await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); + } + function validBrokerRole(role) { + return ["owner", "admin", "user"].includes(role); + } + function normalizeBrokerRole(role) { + return validBrokerRole(role) ? role : "user"; + } + async function signedBrokerUser(event) { + const users = await loadBrokerUsers(); + const publicKey = normalizeKey(header(event, "x-bgit-key")); + const message = String(header(event, "x-bgit-signature-message") || ""); + const signature = String(header(event, "x-bgit-signature") || ""); + if (!publicKey || !message || !signature || message !== expectedMessage(event.body)) return null; + for (const user of users.data.users || []) { + if (user.suspended) continue; + for (const key of user.keys || []) { + const keyValue = normalizeKey(key.public_key || key); + if (keyValue === publicKey && verifySSHSignature(publicKey, message, signature)) return {user: user.username || user.id, user_id: user.id, broker_role: user.broker_role || "user", public_key: publicKey, source: "broker-user"}; + } + } + return null; + } + async function requireBrokerAdmin(event) { + const user = await signedBrokerUser(event); + if (user && (user.broker_role === "owner" || user.broker_role === "admin")) return user; + const owners = await loadOwners(); + const key = signedKey(event, owners); + if (key && (key.role === "owner" || key.role === "admin")) return {user: key.user || "owner", broker_role: key.role, public_key: key.public_key}; + throw Object.assign(new Error("broker admin SSH signature required"), {statusCode: 403}); + } + function teamDocID(teamID) { + return "team:" + String(teamID || "").trim(); + } + async function loadTeam(teamID) { + const id = teamDocID(teamID); + const out = await db.send(new GetItemCommand({TableName: table, Key: {id: {S: id}}})); + if (!out.Item) return null; + const data = JSON.parse(out.Item.data.S || "{}"); + data.members = data.members || []; + return {id, data}; + } + async function saveTeam(entry) { + await db.send(new PutItemCommand({TableName: table, Item: {id: {S: entry.id}, data: {S: JSON.stringify(entry.data)}}})); + } + async function ensureCoreTeam(actor) { + const existing = await loadTeam(coreTeamID); + if (existing) return existing; + const team = {id: coreTeamID, name: coreTeamName, members: [], created_by: actor || "setup", created_at: new Date().toISOString()}; + const entry = {id: teamDocID(coreTeamID), data: team}; + await saveTeam(entry); + return entry; + } + async function loadTeamByName(name) { + const want = String(name || "").trim().toLowerCase(); + if (!want) return null; + if (want === coreTeamName || want === coreTeamID) return ensureCoreTeam("lookup"); + const out = await db.send(new ScanCommand({TableName: table})); + const matches = (out.Items || []) + .filter((item) => String(item.id.S || "").startsWith("team:")) + .map((item) => ({id: item.id.S, data: JSON.parse(item.data.S || "{}")})) + .filter((team) => String(team.data.name || "").trim().toLowerCase() === want || String(team.data.id || "").trim().toLowerCase() === want); + if (matches.length === 0) return null; + if (matches.length > 1) throw Object.assign(new Error("team name is ambiguous"), {statusCode: 409}); + matches[0].data.members = matches[0].data.members || []; + return matches[0]; + } + async function teamRoleForUser(teamID, userID) { + if (!teamID || !userID) return ""; + const team = await loadTeam(teamID); + if (!team) return ""; + const member = (team.data.members || []).find((item) => item.user_id === userID); + return member && !member.suspended ? normalizeRole(member.role || "read") : ""; + } + function strongerRole(a, b) { + const rank = {read: 1, triage: 2, developer: 3, maintainer: 4, admin: 5, owner: 6}; + return (rank[b] || 0) > (rank[a] || 0) ? b : a; + } + function weakerRole(a, b) { + const rank = {read: 1, triage: 2, developer: 3, maintainer: 4, admin: 5, owner: 6}; + if (!a) return b || ""; + if (!b) return a || ""; + return (rank[b] || 0) < (rank[a] || 0) ? b : a; + } + async function effectiveSignedKey(event, entry) { + let direct = signedKey(event, entry); + let role = direct ? direct.role : ""; + const brokerUser = await signedBrokerUser(event); + if (brokerUser) { + for (const grant of entry.data.teams || []) { + const teamRole = await teamRoleForUser(grant.id || grant.team_id, brokerUser.user_id); + if (teamRole) role = strongerRole(role, weakerRole(teamRole, grant.role || teamRole)); + } + } + if (!direct && brokerUser && role) direct = {user: brokerUser.user, role, public_key: brokerUser.public_key, source: "team", user_id: brokerUser.user_id}; + else if (direct && role && role !== direct.role) direct = {...direct, role}; + return direct; + } function audit(entry, event) { entry.data.audit = (entry.data.audit || []).concat([{...event, at: new Date().toISOString()}]).slice(-500); } @@ -412,6 +612,10 @@ Resources: const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, repo, token})).toString("base64url"); return "bgitinv_" + payload; } + function brokerUserInviteCode(brokerURL, user, role, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, user, role, token})).toString("base64url"); + return "bgituser_" + payload; + } function verifySignature(event, entry) { const adminKeys = (entry.data.keys || []).filter((k) => (k.role === "admin" || k.role === "owner") && !k.suspended); if (adminKeys.length === 0) return true; @@ -439,6 +643,34 @@ Resources: item.public_key.includes(value) || keyFingerprint(item.public_key) === value; } + function normalizedUsername(user) { + return String(user || "").trim().toLowerCase(); + } + function assertUniqueRepoKey(entry, publicKey, user) { + const normalized = normalizeKey(publicKey); + if (!normalized) return null; + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalized); + if (existing && normalizedUsername(existing.user) !== normalizedUsername(user)) { + throw Object.assign(new Error("SSH key already belongs to user " + (existing.user || "unknown")), {statusCode: 409}); + } + return existing || null; + } + function assertUniqueBrokerUserKey(users, publicKey, username) { + const normalized = normalizeKey(publicKey); + if (!normalized) return null; + const target = normalizedUsername(username); + for (const user of users.data.users || []) { + for (const key of user.keys || []) { + if (normalizeKey(key.public_key || key) === normalized) { + if (normalizedUsername(user.username) !== target) { + throw Object.assign(new Error("SSH key already belongs to broker user " + (user.username || "unknown")), {statusCode: 409}); + } + return key; + } + } + } + return null; + } function roleCapabilities(role) { return { read: roleAllows(role, "read"), @@ -469,20 +701,33 @@ Resources: function normalizeRole(role) { return role === "write" ? "developer" : role; } - function requireAdmin(event, entry) { - if (!verifySignature(event, entry)) throw Object.assign(new Error("admin SSH signature required"), {statusCode: 403}); + async function requireAdmin(event, entry) { + const key = await effectiveSignedKey(event, entry); + if (!key || (key.role !== "owner" && key.role !== "admin")) { + const brokerUser = await signedBrokerUser(event); + if (brokerUser && (brokerUser.broker_role === "owner" || brokerUser.broker_role === "admin")) return {user: brokerUser.user || "", role: brokerUser.broker_role, broker_role: brokerUser.broker_role, public_key: brokerUser.public_key || "", source: "broker-user"}; + throw Object.assign(new Error("admin SSH signature required"), {statusCode: 403}); + } + return key; + } + async function requireOwner(event, entry) { + const key = await effectiveSignedKey(event, entry); + if (key && key.role === "owner") return key; + const brokerUser = await signedBrokerUser(event); + if (brokerUser && brokerUser.broker_role === "owner") return {user: brokerUser.user || "", role: "owner", broker_role: "owner", public_key: brokerUser.public_key || "", source: "broker-user"}; + throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); } - function requireOperation(event, entry, operation) { + async function requireOperation(event, entry, operation) { if (operation !== "read" && repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); - const key = signedKey(event, entry); + const key = await effectiveSignedKey(event, entry); if (!key && operation === "read" && repoIsPublic(entry)) return anonymousKey(); if (!key || !roleAllows(key.role, operation)) throw Object.assign(new Error(operation + " SSH signature required"), {statusCode: 403}); return key; } - function requireIssueCreate(event, entry) { + async function requireIssueCreate(event, entry) { if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); - if (repoIsPublic(entry)) return signedKey(event, entry) || anonymousKey(); - return requireOperation(event, entry, "read"); + if (repoIsPublic(entry)) return await effectiveSignedKey(event, entry) || anonymousKey(); + return await requireOperation(event, entry, "read"); } function cleanObjectPath(value) { const path = String(value || "").replace(/^\/+/, ""); @@ -796,29 +1041,357 @@ Resources: const role = normalizeRole(body.role || "owner"); if (role !== "owner") throw new Error("owner bootstrap only accepts owner role"); for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); } await saveRepo(entry); + await ensureCoreTeam(user); return response(200, {ok: true}); } - if (path === "/repos/upsert" && method === "POST") { + if (path === "/broker/users/list" && method === "POST") { + await requireBrokerAdmin(event); + const users = await loadBrokerUsers(); + return response(200, {users: users.data.users || []}); + } + if (path === "/broker/users/upsert" && method === "POST") { + await requireBrokerAdmin(event); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || "").trim(); + if (!username) throw new Error("user is required"); + const role = normalizeBrokerRole(body.broker_role || body.role || "user"); + if (!validBrokerRole(body.broker_role || body.role || "user")) throw new Error("invalid broker role"); + if (role === "owner") throw Object.assign(new Error("broker owner role is managed by owner transfer"), {statusCode: 403}); + let user = users.data.users.find((item) => String(item.username || "").toLowerCase() === username.toLowerCase()); + if (user && user.broker_role === "owner") throw Object.assign(new Error("broker owner cannot be reassigned or suspended"), {statusCode: 403}); + if (!user) { + user = {id: "u_" + crypto.randomBytes(6).toString("hex"), username, broker_role: role, keys: [], suspended: false}; + users.data.users.push(user); + } + user.username = username; + user.broker_role = role; + user.suspended = !!body.suspended; + user.keys = user.keys || []; + for (const publicKey of body.public_keys || []) { + if (!assertUniqueBrokerUserKey(users, publicKey, username)) user.keys.push({public_key: publicKey, source: body.source || ""}); + } + await saveBrokerUsers(users); + return response(200, {ok: true, user}); + } + if (path === "/broker/users/delete" && method === "POST") { + await requireBrokerAdmin(event); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || "").trim(); + if (!username) throw new Error("user is required"); + const normalizedUser = username.toLowerCase(); + const user = (users.data.users || []).find((item) => String(item.username || "").toLowerCase() === normalizedUser); + if (!user) throw Object.assign(new Error("broker user not found"), {statusCode: 404}); + if (user.broker_role === "owner") throw Object.assign(new Error("broker owner cannot be deleted"), {statusCode: 403}); + users.data.users = (users.data.users || []).filter((item) => String(item.username || "").toLowerCase() !== normalizedUser); + users.data.invites = (users.data.invites || []).filter((invite) => String(invite.user || "").trim().toLowerCase() !== normalizedUser); + await saveBrokerUsers(users); + for (const key of user.keys || []) { + if (!key.public_key) continue; + const fingerprint = keyFingerprint(key.public_key); + const existing = await db.send(new QueryCommand({TableName: memberTable, KeyConditionExpression: "fingerprint = :fp", ExpressionAttributeValues: {":fp": {S: fingerprint}}})); + for (const item of existing.Items || []) { + await db.send(new DeleteItemCommand({TableName: memberTable, Key: {fingerprint: {S: fingerprint}, repo_id: item.repo_id}})); + } + } + const out = await db.send(new ScanCommand({TableName: table})); + const removedRepoKeys = []; + for (const item of out.Items || []) { + const id = item.id && item.id.S || ""; + if (!id || id.startsWith("_")) continue; + const data = JSON.parse(item.data && item.data.S || "{}"); + let changed = false; + if (id.startsWith("team:")) { + const nextMembers = (data.members || []).filter((member) => String(member.username || "").trim().toLowerCase() !== normalizedUser && String(member.user_id || "") !== user.id); + if (nextMembers.length !== (data.members || []).length) { + data.members = nextMembers; + changed = true; + } + } else { + const originalKeys = data.keys || []; + for (const key of originalKeys) { + if (String(key.user || "").trim().toLowerCase() === normalizedUser && key.public_key) removedRepoKeys.push(key.public_key); + } + const nextKeys = originalKeys.filter((key) => String(key.user || "").trim().toLowerCase() !== normalizedUser); + const nextInvites = (data.invites || []).filter((invite) => String(invite.user || "").trim().toLowerCase() !== normalizedUser); + if (nextKeys.length !== (data.keys || []).length) { + data.keys = nextKeys; + changed = true; + } + if (nextInvites.length !== (data.invites || []).length) { + data.invites = nextInvites; + changed = true; + } + } + if (changed) await db.send(new PutItemCommand({TableName: table, Item: {id: {S: id}, data: {S: JSON.stringify(data)}}})); + } + for (const publicKey of removedRepoKeys) { + const fingerprint = keyFingerprint(publicKey); + const existing = await db.send(new QueryCommand({TableName: memberTable, KeyConditionExpression: "fingerprint = :fp", ExpressionAttributeValues: {":fp": {S: fingerprint}}})); + for (const item of existing.Items || []) { + await db.send(new DeleteItemCommand({TableName: memberTable, Key: {fingerprint: {S: fingerprint}, repo_id: item.repo_id}})); + } + } + return response(200, {ok: true}); + } + if (path === "/broker/users/invite/create" && method === "POST") { + await requireBrokerAdmin(event); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || "").trim(); + if (!username) throw new Error("user is required"); + const role = normalizeBrokerRole(body.broker_role || body.role || "user"); + if (!validBrokerRole(body.broker_role || body.role || "user") || role === "owner") throw new Error("invalid broker role"); + users.data.invites = (users.data.invites || []).filter((invite) => Date.parse(invite.expires_at || "") > Date.now()); + const normalizedUser = username.toLowerCase(); + if (users.data.invites.some((invite) => String(invite.user || "").trim().toLowerCase() === normalizedUser)) throw Object.assign(new Error("broker user invite already pending for user"), {statusCode: 409}); + let user = (users.data.users || []).find((item) => String(item.username || "").toLowerCase() === normalizedUser); + if (!user) { + user = {id: "u_" + crypto.randomBytes(6).toString("hex"), username, broker_role: role, keys: [], suspended: false, pending: true}; + users.data.users.push(user); + } else { + user.username = username; + user.broker_role = role; + if (!(user.keys || []).length) user.pending = true; + } + const token = crypto.randomBytes(24).toString("base64url"); + const brokerURL = String(body.broker_url || "").trim(); + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + users.data.invites.push({token_hash: ownershipTransferTokenHash(token), user: username, role, broker_url: brokerURL, expires_at: expires}); + await saveBrokerUsers(users); + const code = brokerUserInviteCode(brokerURL, username, role, token); + return response(200, {ok: true, code, accept_command: "bgit admin accept-broker-invite " + code, user: username, role}); + } + if (path === "/broker/users/invite/accept" && method === "POST") { + const users = await loadBrokerUsers(); + const signed = submittedSignedKey(event); + if (!signed) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const tokenHash = ownershipTransferTokenHash(body.token); + const invites = users.data.invites || []; + const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || "") > Date.now()); + if (!invite) throw Object.assign(new Error("broker user invite is not pending or has expired"), {statusCode: 404}); + const username = String(invite.user || body.user || "").trim(); + let user = (users.data.users || []).find((item) => String(item.username || "").toLowerCase() === username.toLowerCase()); + if (!user) { + user = {id: "u_" + crypto.randomBytes(6).toString("hex"), username, broker_role: invite.role || "user", keys: [], suspended: false}; + users.data.users.push(user); + } + user.username = username; + user.broker_role = invite.role || user.broker_role || "user"; + user.pending = false; + user.keys = user.keys || []; + if (!assertUniqueBrokerUserKey(users, signed.public_key, username)) user.keys.push({public_key: signed.public_key, source: "broker-invite"}); + users.data.invites = invites.filter((item) => item.token_hash !== tokenHash); + await saveBrokerUsers(users); + await syncAllMembershipIndexes(); + return response(200, {ok: true, user: username, role: user.broker_role, fingerprint: signed.fingerprint}); + } + if (path === "/broker/users/invite/cancel" && method === "POST") { + await requireBrokerAdmin(event); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || "").trim().toLowerCase(); + const invites = users.data.invites || []; + const next = invites.filter((item) => String(item.user || "").trim().toLowerCase() !== username); + if (next.length === invites.length) throw Object.assign(new Error("broker user invite is not pending or has expired"), {statusCode: 404}); + users.data.invites = next; + users.data.users = (users.data.users || []).filter((item) => { + if (String(item.username || "").trim().toLowerCase() !== username) return true; + return (item.keys || []).length > 0 || !item.pending; + }); + await saveBrokerUsers(users); + await syncAllMembershipIndexes(); + return response(200, {ok: true}); + } + if (path === "/teams/create" && method === "POST") { + const actor = await requireBrokerAdmin(event); + const name = String(body.name || "").trim(); + if (!name) throw new Error("team name is required"); + const id = body.id || body.team_id || ("t_" + crypto.randomBytes(6).toString("hex")); + const existing = await loadTeam(id); + if (existing) throw Object.assign(new Error("team already exists"), {statusCode: 409}); + const team = {id, name, members: [], created_by: actor.user || "", created_at: new Date().toISOString()}; + await saveTeam({id: teamDocID(id), data: team}); + return response(200, {ok: true, team}); + } + if (path === "/teams/delete" && method === "POST") { + await requireBrokerAdmin(event); + const teamID = String(body.team_id || body.id || body.name || "").trim(); + if (!teamID) throw new Error("team_id is required"); + const team = await loadTeam(teamID); + if (!team) throw Object.assign(new Error("team not found"), {statusCode: 404}); + if (team.data.id === coreTeamID || String(team.data.name || "").trim().toLowerCase() === coreTeamName) { + throw Object.assign(new Error("core team cannot be deleted"), {statusCode: 403}); + } + let ExclusiveStartKey; + do { + const out = await db.send(new ScanCommand({TableName: table, ExclusiveStartKey})); + for (const item of out.Items || []) { + const id = item.id && item.id.S || ""; + if (id.startsWith("_") || id.startsWith("team:")) continue; + const data = JSON.parse(item.data && item.data.S || "{}"); + const teams = data.teams || []; + const next = teams.filter((grant) => (grant.id || grant.team_id) !== teamID); + if (next.length !== teams.length) { + data.teams = next; + await saveRepo({id, data}); + } + } + ExclusiveStartKey = out.LastEvaluatedKey; + } while (ExclusiveStartKey); + await db.send(new DeleteItemCommand({TableName: table, Key: {id: {S: team.id}}})); + return response(200, {ok: true}); + } + if (path === "/teams/resolve" && method === "POST") { + const team = await loadTeamByName(body.name || body.team || body.team_name); + if (!team) throw Object.assign(new Error("team not found"), {statusCode: 404}); + return response(200, {team: team.data}); + } + if (path === "/teams/list" && method === "POST") { + await requireBrokerAdmin(event); + const out = await db.send(new ScanCommand({TableName: table})); + const teams = (out.Items || []).filter((item) => String(item.id.S || "").startsWith("team:")).map((item) => JSON.parse(item.data.S || "{}")); + return response(200, {teams}); + } + if (path === "/teams/member/upsert" && method === "POST") { + await requireBrokerAdmin(event); + const team = await loadTeam(body.team_id); + if (!team) throw Object.assign(new Error("team not found"), {statusCode: 404}); + const userID = String(body.user_id || "").trim(); + const username = String(body.user || body.username || "").trim(); + const role = normalizeRole(body.role || "read"); + if (!validRole(role)) throw new Error("invalid role"); + if (!userID && !username) throw new Error("user is required"); + let resolvedUserID = userID; + if (!resolvedUserID && username) { + const users = await loadBrokerUsers(); + const user = (users.data.users || []).find((item) => String(item.username || "").toLowerCase() === username.toLowerCase()); + if (!user) throw Object.assign(new Error("broker user not found"), {statusCode: 404}); + resolvedUserID = user.id; + } + team.data.members = (team.data.members || []).filter((item) => item.user_id !== resolvedUserID && String(item.username || "").toLowerCase() !== username.toLowerCase()); + team.data.members.push({user_id: resolvedUserID, username, role, suspended: false}); + await saveTeam(team); + await syncReposForTeam(team.data.id); + return response(200, {ok: true, team: team.data}); + } + if (path === "/teams/member/remove" && method === "POST") { + await requireBrokerAdmin(event); + const team = await loadTeam(body.team_id); + if (!team) throw Object.assign(new Error("team not found"), {statusCode: 404}); + const userID = String(body.user_id || "").trim(); + const username = String(body.user || body.username || "").trim().toLowerCase(); + team.data.members = (team.data.members || []).filter((item) => item.user_id !== userID && String(item.username || "").toLowerCase() !== username); + await saveTeam(team); + await syncReposForTeam(team.data.id); + return response(200, {ok: true, team: team.data}); + } + if (path === "/repos/create" && method === "POST") { + await requireBrokerAdmin(event); + if (await loadExistingRepo(body.repo)) throw Object.assign(new Error("repository already exists"), {statusCode: 409}); const entry = await loadRepo(body.repo); - requireAdmin(event, entry); entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + const user = body.admin_user || "admin"; + const role = normalizeRole(body.role || "developer"); + if (!validRole(role)) throw new Error("invalid role"); + if (body.repo && body.repo.team_id) { + const team = await loadTeam(body.repo.team_id); + if (!team) throw Object.assign(new Error("team not found"), {statusCode: 404}); + entry.data.teams = entry.data.teams || []; + entry.data.teams.push({id: body.repo.team_id, role}); + } + if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); + for (const publicKey of body.public_keys || []) { + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + } + audit(entry, {type: "repo_create", user}); + await saveRepo(entry); + return response(200, {ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix}); + } + if (path === "/repos/get" && method === "POST") { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + try { + await requireOperation(event, entry, "read"); + } catch (err) { + try { + await requireBrokerAdmin(event); + } catch (_) { + throw err; + } + } + return response(200, {ok: true, repo: entry.data.repo || body.repo, teams: entry.data.teams || []}); + } + if (path === "/repos/upsert" && method === "POST") { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireAdmin(event, entry); + entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + if (body.repo && body.repo.team_id && !(entry.data.teams || []).find((t) => (t.id || t.team_id) === body.repo.team_id)) { + const team = await loadTeam(body.repo.team_id); + if (!team) throw Object.assign(new Error("team not found"), {statusCode: 404}); + entry.data.teams = entry.data.teams || []; + entry.data.teams.push({id: body.repo.team_id, role: body.role || "developer"}); + } if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); const user = body.admin_user || "admin"; const role = normalizeRole(body.role || "admin"); if (!validRole(role)) throw new Error("invalid role"); for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); } audit(entry, {type: "repo_upsert", user}); await saveRepo(entry); return response(200, {ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix}); } + if (path === "/repo/teams/list" && method === "POST") { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireAdmin(event, entry); + return response(200, {teams: entry.data.teams || []}); + } + if (path === "/repo/teams/upsert" && method === "POST") { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireAdmin(event, entry); + const teamID = String(body.team_id || "").trim(); + if (!teamID) throw new Error("team_id is required"); + const team = await loadTeam(teamID); + if (!team) throw Object.assign(new Error("team not found"), {statusCode: 404}); + const role = normalizeRole(body.role || "read"); + if (!validRole(role)) throw new Error("invalid role"); + entry.data.teams = (entry.data.teams || []).filter((item) => (item.id || item.team_id) !== teamID); + entry.data.teams.push({id: teamID, role}); + await saveRepo(entry); + return response(200, {ok: true, teams: entry.data.teams}); + } + if (path === "/repos/list" && method === "POST") { + await requireBrokerAdmin(event); + const out = await db.send(new ScanCommand({TableName: table})); + const reposOut = (out.Items || []).map((item) => { + const id = item.id && item.id.S || ""; + if (id.startsWith("_") || id.startsWith("team:")) return null; + const data = JSON.parse(item.data && item.data.S || "{}"); + const repo = data.repo || {}; + if (!repo.logical) return null; + return {repo, logical: repo.logical, teams: data.teams || []}; + }).filter(Boolean); + reposOut.sort((a, b) => String(a.logical || "").localeCompare(String(b.logical || ""))); + return response(200, {repos: reposOut}); + } + if (path === "/repo/teams/remove" && method === "POST") { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireAdmin(event, entry); + const teamID = String(body.team_id || "").trim(); + entry.data.teams = (entry.data.teams || []).filter((item) => (item.id || item.team_id) !== teamID); + await saveRepo(entry); + return response(200, {ok: true, teams: entry.data.teams || []}); + } if (path === "/repo/info" && method === "POST") { - const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireOperation(event, entry, "read"); return response(200, { repo: entry.data.repo || body.repo, description: entry.data.description || "", @@ -830,7 +1403,7 @@ Resources: } if (path === "/repo/update" && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); if (Object.prototype.hasOwnProperty.call(body, "description")) entry.data.description = String(body.description || "").trim(); if (Object.prototype.hasOwnProperty.call(body, "default_branch")) entry.data.default_branch = String(body.default_branch || "").trim() || "main"; if (Object.prototype.hasOwnProperty.call(body, "visibility")) entry.data.visibility = body.visibility === "public" ? "public" : "private"; @@ -850,8 +1423,7 @@ Resources: } if (path === "/repo/rename" && method === "POST") { const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const key = await requireOwner(event, entry); const logical = normalizeLogicalRepo(body.logical); const newRepo = {...(entry.data.repo || body.repo), logical}; const newID = docID(newRepo); @@ -872,8 +1444,7 @@ Resources: } if (path === "/repo/delete" && method === "POST") { const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + await requireOwner(event, entry); const repo = await ensurePhysicalRepo(entry); await deletePhysicalRepo(repo); await deleteRepoMetadata(entry); @@ -881,24 +1452,25 @@ Resources: } if (path === "/keys/list" && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); return response(200, {keys: entry.data.keys}); } if (path === "/keys/add" && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); const user = body.user || "admin"; const role = normalizeRole(body.role || "read"); if (!validRole(role) || role === "owner") throw new Error("invalid role"); for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || "", suspended: false}); } await saveRepo(entry); return response(200, {ok: true}); } if (path === "/keys/invite/create" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireAdmin(event, entry); const user = String(body.user || "").trim(); const role = normalizeRole(body.role || "read"); if (!user) throw new Error("user is required"); @@ -915,6 +1487,15 @@ Resources: await saveRepo(entry); return response(200, {ok: true, code, accept_command: "bgit admin accept-invite " + code}); } + if (path === "/keys/invite/list" && method === "POST") { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireAdmin(event, entry); + entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || "") > Date.now()); + await saveRepo(entry); + const invites = (entry.data.invites || []).map((invite) => ({user: invite.user || "", role: invite.role || "read", expires_at: invite.expires_at || ""})); + return response(200, {invites}); + } if (path === "/keys/invite/accept" && method === "POST") { const entry = await loadRepo(body.repo); const signed = submittedSignedKey(event); @@ -923,7 +1504,7 @@ Resources: const invites = entry.data.invites || []; const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || "") > Date.now()); if (!invite) throw Object.assign(new Error("invite is not pending or has expired"), {statusCode: 404}); - const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(signed.public_key)); + const existing = assertUniqueRepoKey(entry, signed.public_key, invite.user); if (existing) { existing.user = invite.user; existing.role = invite.role; @@ -938,8 +1519,9 @@ Resources: return response(200, {ok: true, user: invite.user, role: invite.role, fingerprint: signed.fingerprint}); } if (path === "/keys/invite/cancel" && method === "POST") { - const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + await requireAdmin(event, entry); const invites = entry.data.invites || []; const user = String(body.user || "").trim().toLowerCase(); const tokenHash = body.token ? ownershipTransferTokenHash(body.token) : ""; @@ -955,7 +1537,7 @@ Resources: } if ((path === "/keys/remove" || path === "/keys/suspend") && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); const key = String(body.key || "").trim(); const match = (k) => keyMatches(k, key); if (entry.data.keys.some((k) => match(k) && k.role === "owner")) throw Object.assign(new Error("owners cannot be removed or suspended"), {statusCode: 403}); @@ -978,7 +1560,7 @@ Resources: } if (path === "/keys/unsuspend" && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); const key = String(body.key || "").trim(); const match = (k) => keyMatches(k, key); let changed = false; @@ -993,9 +1575,9 @@ Resources: return response(200, {ok: true}); } if (path === "/owners/transfer/confirm" && method === "POST") { - const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + const key = await requireOwner(event, entry); if (entry.data.owner_transfer && !ownershipTransferExpired(entry.data.owner_transfer)) { throw Object.assign(new Error("ownership transfer already pending; run bgit admin cancel-ownership-transfer to cancel it"), {statusCode: 409}); } @@ -1015,9 +1597,9 @@ Resources: return response(200, {ok: true, code, accept_command: "bgit admin accept-ownership-transfer " + code, cancel_command: "bgit admin cancel-ownership-transfer --broker " + brokerURL + " " + ((entry.data.repo || body.repo).logical || "")}); } if (path === "/owners/transfer/cancel" && method === "POST") { - const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); - if (!key || key.role !== "owner") throw Object.assign(new Error("owner SSH signature required"), {statusCode: 403}); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error("repository not found"), {statusCode: 404}); + const key = await requireOwner(event, entry); delete entry.data.owner_transfer; audit(entry, {type: "owner_transfer_cancel", user: key.user || ""}); await saveRepo(entry); @@ -1035,7 +1617,7 @@ Resources: for (const item of entry.data.keys || []) { if (item.role === "owner" && keyFingerprint(item.public_key) === ownerFingerprint) item.role = "admin"; } - const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(accepted.public_key)); + const existing = assertUniqueRepoKey(entry, accepted.public_key, user); if (existing) { existing.role = "owner"; existing.user = user; @@ -1051,12 +1633,12 @@ Resources: } if (path === "/protection/list" && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); return response(200, {protections: entry.data.protections || []}); } if (path === "/protection/upsert" && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); entry.data.protections.push({ref: body.ref, require_pr: body.require_pr !== false, allow_overrides: !!body.allow_overrides}); audit(entry, {type: "protection_upsert", ref: body.ref}); @@ -1065,7 +1647,7 @@ Resources: } if (path === "/protection/remove" && method === "POST") { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); audit(entry, {type: "protection_remove", ref: body.ref}); await saveRepo(entry); @@ -1073,14 +1655,14 @@ Resources: } if (path === "/issues/list" && method === "POST") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + await requireOperation(event, entry, "read"); if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); const issues = await listIssues(entry); return response(200, {issues}); } if (path === "/issues/view" && method === "POST") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + await requireOperation(event, entry, "read"); if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); @@ -1089,7 +1671,7 @@ Resources: if (path === "/issues/create" && method === "POST") { const entry = await loadRepo(body.repo); if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); - const key = requireIssueCreate(event, entry); + const key = await requireIssueCreate(event, entry); const title = String(body.title || "").trim(); const issueBody = String(body.body || "").trim(); if (!title) throw new Error("issue title is required"); @@ -1101,7 +1683,7 @@ Resources: if (path === "/issues/comment" && method === "POST") { const entry = await loadRepo(body.repo); if (entry.data.issues_enabled === false) throw Object.assign(new Error("issues are disabled"), {statusCode: 403}); - const key = requireIssueCreate(event, entry); + const key = await requireIssueCreate(event, entry); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); const comment = String(body.comment || "").trim(); @@ -1114,7 +1696,7 @@ Resources: } if (path === "/issues/close" || path === "/issues/reopen") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "write"); + await requireOperation(event, entry, "write"); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error("issue not found"), {statusCode: 404}); issue.status = path === "/issues/reopen" ? "open" : "closed"; @@ -1124,7 +1706,7 @@ Resources: } if (path === "/prs/create" && method === "POST") { const entry = await loadRepo(body.repo); - const key = requireOperation(event, entry, "write"); + const key = await requireOperation(event, entry, "write"); entry.data.refs = entry.data.refs || {}; const pr = {...(body.pr || {})}; pr.id = nextPRID(entry.data); @@ -1141,24 +1723,24 @@ Resources: } if (path === "/prs/list" && method === "POST") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + await requireOperation(event, entry, "read"); return response(200, {prs: await listPRs(entry)}); } if (path === "/prs/sync" && method === "POST") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + await requireOperation(event, entry, "read"); return response(200, await syncPRRecords(entry, body.known || {})); } if (path === "/prs/view" && method === "POST") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + await requireOperation(event, entry, "read"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); return response(200, {pr}); } if (path === "/prs/close" && method === "POST") { const entry = await loadRepo(body.repo); - const key = requireOperation(event, entry, "write"); + const key = await requireOperation(event, entry, "write"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); pr.status = "closed"; @@ -1172,7 +1754,7 @@ Resources: } if (path === "/prs/reopen" && method === "POST") { const entry = await loadRepo(body.repo); - const key = requireOperation(event, entry, "write"); + const key = await requireOperation(event, entry, "write"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); pr.status = "open"; @@ -1187,7 +1769,7 @@ Resources: if (path === "/prs/comment" && method === "POST") { const entry = await loadRepo(body.repo); if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); - const key = requireOperation(event, entry, "read"); + const key = await requireOperation(event, entry, "read"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); const comment = String(body.comment || "").trim(); @@ -1203,7 +1785,7 @@ Resources: if (path === "/prs/reply" && method === "POST") { const entry = await loadRepo(body.repo); if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); - const key = requireOperation(event, entry, "read"); + const key = await requireOperation(event, entry, "read"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); const comment = String(body.comment || "").trim(); @@ -1220,7 +1802,7 @@ Resources: } if (path === "/prs/review" && method === "POST") { const entry = await loadRepo(body.repo); - const key = requireOperation(event, entry, "write"); + const key = await requireOperation(event, entry, "write"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); const state = String(body.review || "").trim(); @@ -1238,7 +1820,7 @@ Resources: if (path === "/prs/merge" && method === "POST") { const entry = await loadRepo(body.repo); if (repoIsReadOnly(entry)) throw Object.assign(new Error("repository is read-only"), {statusCode: 403}); - const key = requireOperation(event, entry, "merge"); + const key = await requireOperation(event, entry, "merge"); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error("pull request not found"), {statusCode: 404}); if (pr.status !== "open") throw new Error("pull request is not open"); @@ -1265,22 +1847,30 @@ Resources: } if (path === "/auth/check" && method === "POST") { const entry = await loadRepo(body.repo); - const key = signedKey(event, entry); + const key = await effectiveSignedKey(event, entry); const operation = body.operation || ""; const allowed = (operation === "read" && repoIsPublic(entry)) || (!!key && roleAllows(key.role, operation)); return response(200, {allowed, user: key && key.user || (allowed ? "anonymous" : ""), role: key && key.role || (allowed ? "read" : "")}); } if (path === "/auth/status" && method === "POST") { const entry = await loadRepo(body.repo); - const key = signedKey(event, entry) || (repoIsPublic(entry) ? anonymousKey() : null); + let key = await effectiveSignedKey(event, entry) || (repoIsPublic(entry) ? anonymousKey() : null); + const brokerAdmin = await requireBrokerAdmin(event).catch(() => null); + if (!key && brokerAdmin) key = {user: brokerAdmin.user || "", role: "", broker_role: brokerAdmin.broker_role || "admin", public_key: brokerAdmin.public_key || "", source: "broker-admin"}; if (!key) throw Object.assign(new Error("SSH signature required"), {statusCode: 403}); + const capabilities = roleCapabilities(key.role || ""); + if (brokerAdmin) { + capabilities.admin_keys = true; + capabilities.manage_protection = true; + capabilities.broker_upgrade = true; + } return response(200, { broker_version: brokerVersion, repo: entry.data.repo || body.repo, identity: {user: key.user || "", source: key.source || "", key_fingerprint: key.public_key ? keyFingerprint(key.public_key) : "", public_key: key.public_key || ""}, user: key.user || "", role: key.role || "", - capabilities: roleCapabilities(key.role || ""), + capabilities, resolved_at: new Date().toISOString(), }); } @@ -1303,7 +1893,7 @@ Resources: if (path === "/members/reindex" && method === "POST") { if (body.repo && (body.repo.logical || body.repo.bucket || body.repo.prefix)) { const entry = await loadRepo(body.repo); - requireAdmin(event, entry); + await requireAdmin(event, entry); await syncMembershipIndex(entry); return response(200, {ok: true, repositories: 1}); } @@ -1315,7 +1905,7 @@ Resources: if (path === "/objects/capability" && method === "POST") { const entry = await loadRepo(body.repo); const operation = body.operation || "read"; - const key = requireOperation(event, entry, operation === "read" ? "read" : "write"); + const key = await requireOperation(event, entry, operation === "read" ? "read" : "write"); const repo = await ensurePhysicalRepo(entry); const capability = await objectCapability(repo, body.path, operation, key); audit(entry, {type: "capability_issued", operation, path: body.path, user: key.user, role: key.role}); @@ -1324,19 +1914,19 @@ Resources: } if (path === "/objects/read" && method === "POST") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + await requireOperation(event, entry, "read"); const repo = await ensurePhysicalRepo(entry); return response(200, {data: await readObject(repo, body.path)}); } if (path === "/objects/list" && method === "POST") { const entry = await loadRepo(body.repo); - requireOperation(event, entry, "read"); + await requireOperation(event, entry, "read"); const repo = await ensurePhysicalRepo(entry); return response(200, {paths: await listObjects(repo, body.prefix)}); } if (path === "/refs/update" && method === "POST") { const entry = await loadRepo(body.repo); - const key = requireOperation(event, entry, "write"); + const key = await requireOperation(event, entry, "write"); await updateRefCAS(body.repo, body.ref, body.old, body.new, key, {override: !!body.override}); return response(200, {ok: true}); } diff --git a/broker/gcp/index.js b/broker/gcp/index.js index f1979de..baf695e 100644 --- a/broker/gcp/index.js +++ b/broker/gcp/index.js @@ -12,8 +12,15 @@ const storage = new Storage(); const auth = new GoogleAuth({scopes: ['https://www.googleapis.com/auth/cloud-platform']}); const brokerVersion = process.env.BROKER_VERSION || '{{BROKER_VERSION}}'; const zero = '0000000000000000000000000000000000000000'; +const coreTeamID = 't_core'; +const coreTeamName = 'core'; function repoID(repo) { + if (repo && repo.logical) return ['logical', repo.team_id || coreTeamID, repo.logical].join(':'); + return [repo.provider || 'gcs', repo.bucket, repo.prefix].join(':'); +} + +function legacyRepoID(repo) { if (repo && repo.logical) return ['logical', repo.logical].join(':'); return [repo.provider || 'gcs', repo.bucket, repo.prefix].join(':'); } @@ -22,6 +29,10 @@ function docID(repo) { return Buffer.from(repoID(repo)).toString('base64url'); } +function legacyDocID(repo) { + return Buffer.from(legacyRepoID(repo)).toString('base64url'); +} + function cleanName(value) { return String(value || 'repo').toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'repo'; } @@ -37,6 +48,7 @@ function normalizeLogicalRepo(value) { function validateRepo(repo) { if (!repo || (!repo.logical && (!repo.bucket || !repo.prefix))) throw new Error('repo is required'); if (repo.logical) repo.logical = normalizeLogicalRepo(repo.logical); + if (repo.logical && !repo.team_id) repo.team_id = coreTeamID; return repo; } @@ -45,45 +57,121 @@ function randomSuffix() { } async function loadRepo(repo) { + const hadLogicalNoTeam = !!(repo && repo.logical && !repo.team_id); repo = validateRepo(repo); const ref = repos.doc(docID(repo)); const snap = await ref.get(); + if (!snap.exists && (hadLogicalNoTeam || repo.team_id === coreTeamID)) { + const legacyRef = repos.doc(legacyDocID(repo)); + const legacySnap = await legacyRef.get(); + if (legacySnap.exists) { + const legacyData = legacySnap.data() || {}; + legacyData.repo = {...(legacyData.repo || repo), team_id: coreTeamID}; + legacyData.keys = legacyData.keys || []; + legacyData.audit = legacyData.audit || []; + return {ref: legacyRef, data: legacyData}; + } + } if (!snap.exists) return {ref, data: {repo, keys: [], audit: []}}; const data = snap.data() || {}; data.repo = data.repo || repo; + if (data.repo.logical && !data.repo.team_id) data.repo.team_id = coreTeamID; data.keys = data.keys || []; data.audit = data.audit || []; return {ref, data}; } +async function loadExistingRepo(repo) { + const hadLogicalNoTeam = !!(repo && repo.logical && !repo.team_id); + repo = validateRepo(repo); + const ref = repos.doc(docID(repo)); + const snap = await ref.get(); + if (snap.exists) { + const data = snap.data() || {}; + data.repo = data.repo || repo; + if (data.repo.logical && !data.repo.team_id) data.repo.team_id = coreTeamID; + data.keys = data.keys || []; + data.audit = data.audit || []; + return {ref, data}; + } + if (hadLogicalNoTeam || repo.team_id === coreTeamID) { + const legacyRef = repos.doc(legacyDocID(repo)); + const legacySnap = await legacyRef.get(); + if (legacySnap.exists) { + const legacyData = legacySnap.data() || {}; + legacyData.repo = {...(legacyData.repo || repo), team_id: coreTeamID}; + legacyData.keys = legacyData.keys || []; + legacyData.audit = legacyData.audit || []; + return {ref: legacyRef, data: legacyData}; + } + } + return null; +} + async function saveRepo(entry) { await entry.ref.set(entry.data, {merge: false}); await syncMembershipIndex(entry); } async function syncMembershipIndex(entry) { - const repo = entry.data.repo || {}; - if (!repo.logical && (!repo.bucket || !repo.prefix)) return; - const repoIDValue = repoID(repo); - const logical = repo.logical || repo.prefix || repoIDValue; - const writes = []; + const repo = entry.data.repo || {}; + if (!repo.logical && (!repo.bucket || !repo.prefix)) return; + const repoIDValue = repoID(repo); + const logical = repo.logical || repo.prefix || repoIDValue; + const users = await loadBrokerUsers(); + const usersByID = new Map((users.data.users || []).map((user) => [user.id, user])); + const memberships = new Map(); + function addMembership(publicKey, user, role, source, suspended) { + if (!publicKey) return; + const fingerprint = keyFingerprint(publicKey); + const existing = memberships.get(fingerprint); + const nextRole = existing ? strongerRole(existing.role, role || '') : role || ''; + memberships.set(fingerprint, {public_key: publicKey, user: user || '', role: nextRole, source: source || '', suspended: !!suspended}); + } for (const key of entry.data.keys || []) { - if (!key.public_key) continue; - const fingerprint = keyFingerprint(key.public_key); + addMembership(key.public_key, key.user, key.role, key.source, key.suspended); + } + for (const grant of entry.data.teams || []) { + const team = await loadTeam(grant.id || grant.team_id); + if (!team) continue; + for (const member of team.data.members || []) { + if (member.suspended) continue; + const user = usersByID.get(member.user_id); + if (!user || user.suspended || user.pending) continue; + const role = weakerRole(normalizeRole(member.role || 'read'), normalizeRole(grant.role || 'read')); + for (const key of user.keys || []) { + addMembership(key.public_key || key, user.username || member.username || member.user_id, role, 'team', false); + } + } + } + const writes = []; + for (const membership of memberships.values()) { + const fingerprint = keyFingerprint(membership.public_key); writes.push(members.doc(memberDocID(fingerprint)).collection('repos').doc(docID(repo)).set({ repo_id: repoIDValue, logical, repo, - user: key.user || '', - role: key.role || '', - source: key.source || '', - suspended: !!key.suspended, + user: membership.user || '', + role: membership.role || '', + source: membership.source || '', + suspended: !!membership.suspended, updated_at: new Date().toISOString(), }, {merge: true})); } await Promise.all(writes); } +async function syncReposForTeam(teamID) { + const snap = await repos.get(); + const writes = []; + snap.forEach((doc) => { + if (String(doc.id || '').startsWith('_') || String(doc.id || '').startsWith('team:')) return; + const data = doc.data() || {}; + if ((data.teams || []).some((grant) => (grant.id || grant.team_id) === teamID)) writes.push(syncMembershipIndex({ref: doc.ref, data})); + }); + await Promise.all(writes); +} + async function syncAllMembershipIndexes() { const snap = await repos.get(); const writes = []; @@ -140,6 +228,140 @@ async function loadOwners() { return {ref, data}; } +async function loadBrokerUsers() { + const ref = repos.doc('_users'); + const snap = await ref.get(); + const data = snap.exists ? (snap.data() || {}) : {}; + data.users = data.users || []; + const owners = await loadOwners(); + for (const key of owners.data.keys || []) { + const user = key.user || 'owner'; + let existing = data.users.find((item) => String(item.username || '').toLowerCase() === String(user).toLowerCase()); + if (!existing) { + existing = {id: 'u_' + cleanName(user), username: user, broker_role: key.role === 'admin' ? 'admin' : 'owner', keys: [], suspended: false}; + data.users.push(existing); + } + if (key.public_key && !existing.keys.find((k) => normalizeKey(k.public_key || k) === normalizeKey(key.public_key))) { + existing.keys.push({public_key: key.public_key, source: key.source || 'owner'}); + } + } + return {ref, data}; +} + +async function saveBrokerUsers(entry) { + await entry.ref.set(entry.data, {merge: false}); +} + +function validBrokerRole(role) { + return ['owner', 'admin', 'user'].includes(role); +} + +function normalizeBrokerRole(role) { + return validBrokerRole(role) ? role : 'user'; +} + +async function signedBrokerUser(req) { + const users = await loadBrokerUsers(); + const publicKey = normalizeKey(req.get('x-bgit-key')); + const message = String(req.get('x-bgit-signature-message') || ''); + const signature = String(req.get('x-bgit-signature') || ''); + if (!publicKey || !message || !signature || message !== expectedMessage(req)) return null; + for (const user of users.data.users || []) { + if (user.suspended) continue; + for (const key of user.keys || []) { + const keyValue = normalizeKey(key.public_key || key); + if (keyValue === publicKey && verifySSHSignature(publicKey, message, signature)) { + return {user: user.username || user.id, user_id: user.id, broker_role: user.broker_role || 'user', public_key: publicKey, source: 'broker-user'}; + } + } + } + return null; +} + +async function requireBrokerAdmin(req) { + const user = await signedBrokerUser(req); + if (user && (user.broker_role === 'owner' || user.broker_role === 'admin')) return user; + const owners = await loadOwners(); + const key = signedKey(req, owners); + if (key && (key.role === 'owner' || key.role === 'admin')) return {user: key.user || 'owner', broker_role: key.role, public_key: key.public_key}; + throw Object.assign(new Error('broker admin SSH signature required'), {status: 403}); +} + +function teamDocID(teamID) { + return 'team:' + String(teamID || '').trim(); +} + +async function loadTeam(teamID) { + const ref = repos.doc(teamDocID(teamID)); + const snap = await ref.get(); + if (!snap.exists) return null; + const data = snap.data() || {}; + data.members = data.members || []; + return {ref, data}; +} + +async function ensureCoreTeam(actor) { + const existing = await loadTeam(coreTeamID); + if (existing) return existing; + const ref = repos.doc(teamDocID(coreTeamID)); + const team = {id: coreTeamID, name: coreTeamName, members: [], created_by: actor || 'setup', created_at: new Date().toISOString()}; + await ref.set(team, {merge: false}); + return {ref, data: team}; +} + +async function loadTeamByName(name) { + const want = String(name || '').trim().toLowerCase(); + if (!want) return null; + if (want === coreTeamName || want === coreTeamID) return ensureCoreTeam('lookup'); + const snap = await repos.get(); + const matches = snap.docs + .filter((doc) => String(doc.id || '').startsWith('team:')) + .map((doc) => ({ref: doc.ref, data: doc.data() || {}})) + .filter((team) => String(team.data.name || '').trim().toLowerCase() === want || String(team.data.id || '').trim().toLowerCase() === want); + if (matches.length === 0) return null; + if (matches.length > 1) throw Object.assign(new Error('team name is ambiguous'), {status: 409}); + matches[0].data.members = matches[0].data.members || []; + return matches[0]; +} + +async function teamRoleForUser(teamID, userID) { + if (!teamID || !userID) return ''; + const team = await loadTeam(teamID); + if (!team) return ''; + const member = (team.data.members || []).find((item) => item.user_id === userID); + return member && !member.suspended ? normalizeRole(member.role || 'read') : ''; +} + +function strongerRole(a, b) { + const rank = {read: 1, triage: 2, developer: 3, maintainer: 4, admin: 5, owner: 6}; + return (rank[b] || 0) > (rank[a] || 0) ? b : a; +} + +function weakerRole(a, b) { + const rank = {read: 1, triage: 2, developer: 3, maintainer: 4, admin: 5, owner: 6}; + if (!a) return b || ''; + if (!b) return a || ''; + return (rank[b] || 0) < (rank[a] || 0) ? b : a; +} + +async function effectiveSignedKey(req, entry) { + let direct = signedKey(req, entry); + let role = direct ? direct.role : ''; + const brokerUser = await signedBrokerUser(req); + if (brokerUser) { + for (const grant of entry.data.teams || []) { + const teamRole = await teamRoleForUser(grant.id || grant.team_id, brokerUser.user_id); + if (teamRole) role = strongerRole(role, weakerRole(teamRole, grant.role || teamRole)); + } + } + if (!direct && brokerUser && role) { + direct = {user: brokerUser.user, role, public_key: brokerUser.public_key, source: 'team', user_id: brokerUser.user_id}; + } else if (direct && role && role !== direct.role) { + direct = {...direct, role}; + } + return direct; +} + function audit(entry, event) { entry.data.audit = entry.data.audit || []; entry.data.audit.push({...event, at: new Date().toISOString()}); @@ -291,6 +513,11 @@ function memberInviteCode(brokerURL, repo, token) { return 'bgitinv_' + payload; } +function brokerUserInviteCode(brokerURL, user, role, token) { + const payload = Buffer.from(JSON.stringify({broker_url: brokerURL, user, role, token})).toString('base64url'); + return 'bgituser_' + payload; +} + function verifySignature(req, entry) { const adminKeys = (entry.data.keys || []).filter((k) => (k.role === 'admin' || k.role === 'owner') && !k.suspended); if (adminKeys.length === 0) return true; @@ -323,6 +550,37 @@ function keyMatches(item, key) { keyFingerprint(item.public_key) === value; } +function normalizedUsername(user) { + return String(user || '').trim().toLowerCase(); +} + +function assertUniqueRepoKey(entry, publicKey, user) { + const normalized = normalizeKey(publicKey); + if (!normalized) return null; + const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalized); + if (existing && normalizedUsername(existing.user) !== normalizedUsername(user)) { + throw Object.assign(new Error('SSH key already belongs to user ' + (existing.user || 'unknown')), {status: 409}); + } + return existing || null; +} + +function assertUniqueBrokerUserKey(users, publicKey, username) { + const normalized = normalizeKey(publicKey); + if (!normalized) return null; + const target = normalizedUsername(username); + for (const user of users.data.users || []) { + for (const key of user.keys || []) { + if (normalizeKey(key.public_key || key) === normalized) { + if (normalizedUsername(user.username) !== target) { + throw Object.assign(new Error('SSH key already belongs to broker user ' + (user.username || 'unknown')), {status: 409}); + } + return key; + } + } + } + return null; +} + function memberDocID(fingerprint) { return Buffer.from(String(fingerprint || '')).toString('base64url'); } @@ -363,16 +621,32 @@ function normalizeRole(role) { return role === 'write' ? 'developer' : role; } -function requireAdmin(req, entry) { - if (!verifySignature(req, entry)) { +async function requireAdmin(req, entry) { + const key = await effectiveSignedKey(req, entry); + if (!key || (key.role !== 'owner' && key.role !== 'admin')) { + const brokerUser = await signedBrokerUser(req); + if (brokerUser && (brokerUser.broker_role === 'owner' || brokerUser.broker_role === 'admin')) { + return {user: brokerUser.user || '', role: brokerUser.broker_role, broker_role: brokerUser.broker_role, public_key: brokerUser.public_key || '', source: 'broker-user'}; + } const err = new Error('admin SSH signature required'); err.status = 403; throw err; } + return key; } -function requireRead(req, entry) { - const key = signedKey(req, entry); +async function requireOwner(req, entry) { + const key = await effectiveSignedKey(req, entry); + if (key && key.role === 'owner') return key; + const brokerUser = await signedBrokerUser(req); + if (brokerUser && brokerUser.broker_role === 'owner') { + return {user: brokerUser.user || '', role: 'owner', broker_role: 'owner', public_key: brokerUser.public_key || '', source: 'broker-user'}; + } + throw Object.assign(new Error('owner SSH signature required'), {status: 403}); +} + +async function requireRead(req, entry) { + const key = await effectiveSignedKey(req, entry); if (!key && repoIsPublic(entry)) return anonymousKey(); if (!key || !roleAllows(key.role, 'read')) { const err = new Error('read SSH signature required'); @@ -382,13 +656,13 @@ function requireRead(req, entry) { return key; } -function requireWrite(req, entry) { +async function requireWrite(req, entry) { if (repoIsReadOnly(entry)) { const err = new Error('repository is read-only'); err.status = 403; throw err; } - const key = signedKey(req, entry); + const key = await effectiveSignedKey(req, entry); if (!key || !roleAllows(key.role, 'write')) { const err = new Error('write SSH signature required'); err.status = 403; @@ -397,9 +671,9 @@ function requireWrite(req, entry) { return key; } -function requireIssueCreate(req, entry) { +async function requireIssueCreate(req, entry) { if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); - if (repoIsPublic(entry)) return signedKey(req, entry) || anonymousKey(); + if (repoIsPublic(entry)) return await effectiveSignedKey(req, entry) || anonymousKey(); return requireRead(req, entry); } @@ -705,12 +979,10 @@ function countApprovals(pr) { async function ensureRepo(repo) { repo = validateRepo(repo); const entry = await loadRepo(repo); + if (entry.data.repo && entry.data.repo.logical && entry.data.repo.team_id === coreTeamID) await ensureCoreTeam('repo'); const owners = await loadOwners(); - for (const owner of owners.data.keys || []) { - if (owner.role === 'owner' && !entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(owner.public_key))) { - entry.data.keys.push(owner); - } - } + const ownerKeys = new Set((owners.data.keys || []).filter((owner) => owner.role === 'owner').map((owner) => normalizeKey(owner.public_key))); + entry.data.keys = (entry.data.keys || []).filter((key) => !(key.role === 'owner' && ownerKeys.has(normalizeKey(key.public_key)) && key.source !== 'ownership-transfer')); return entry; } @@ -729,31 +1001,368 @@ exports.broker = async (req, res) => { const role = normalizeRole(body.role || 'owner'); if (role !== 'owner') throw new Error('owner bootstrap only accepts owner role'); for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); } await saveRepo(entry); + await ensureCoreTeam(user); res.status(200).send(JSON.stringify({ok: true})); return; } + if (req.path === '/broker/users/list' && req.method === 'POST') { + await requireBrokerAdmin(req); + const users = await loadBrokerUsers(); + res.status(200).send(JSON.stringify({users: users.data.users || []})); + return; + } + if (req.path === '/broker/users/upsert' && req.method === 'POST') { + await requireBrokerAdmin(req); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || '').trim(); + if (!username) throw new Error('user is required'); + const role = normalizeBrokerRole(body.broker_role || body.role || 'user'); + if (!validBrokerRole(body.broker_role || body.role || 'user')) throw new Error('invalid broker role'); + if (role === 'owner') throw Object.assign(new Error('broker owner role is managed by owner transfer'), {status: 403}); + let user = users.data.users.find((item) => String(item.username || '').toLowerCase() === username.toLowerCase()); + if (user && user.broker_role === 'owner') throw Object.assign(new Error('broker owner cannot be reassigned or suspended'), {status: 403}); + if (!user) { + user = {id: 'u_' + crypto.randomBytes(6).toString('hex'), username, broker_role: role, keys: [], suspended: false}; + users.data.users.push(user); + } + user.username = username; + user.broker_role = role; + user.suspended = !!body.suspended; + user.keys = user.keys || []; + for (const publicKey of body.public_keys || []) { + if (!assertUniqueBrokerUserKey(users, publicKey, username)) user.keys.push({public_key: publicKey, source: body.source || ''}); + } + await saveBrokerUsers(users); + res.status(200).send(JSON.stringify({ok: true, user})); + return; + } + if (req.path === '/broker/users/delete' && req.method === 'POST') { + await requireBrokerAdmin(req); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || '').trim(); + if (!username) throw new Error('user is required'); + const normalizedUser = username.toLowerCase(); + const user = (users.data.users || []).find((item) => String(item.username || '').toLowerCase() === normalizedUser); + if (!user) throw Object.assign(new Error('broker user not found'), {status: 404}); + if (user.broker_role === 'owner') throw Object.assign(new Error('broker owner cannot be deleted'), {status: 403}); + users.data.users = (users.data.users || []).filter((item) => String(item.username || '').toLowerCase() !== normalizedUser); + users.data.invites = (users.data.invites || []).filter((invite) => String(invite.user || '').trim().toLowerCase() !== normalizedUser); + await saveBrokerUsers(users); + for (const key of user.keys || []) { + if (!key.public_key) continue; + const idx = await members.doc(memberDocID(keyFingerprint(key.public_key))).collection('repos').get(); + await Promise.all(idx.docs.map((doc) => doc.ref.delete())); + } + const snap = await repos.get(); + const removedRepoKeys = []; + for (const doc of snap.docs) { + const id = String(doc.id || ''); + if (id.startsWith('_')) continue; + const data = doc.data() || {}; + let changed = false; + if (id.startsWith('team:')) { + const nextMembers = (data.members || []).filter((member) => String(member.username || '').trim().toLowerCase() !== normalizedUser && String(member.user_id || '') !== user.id); + if (nextMembers.length !== (data.members || []).length) { + data.members = nextMembers; + changed = true; + } + } else { + const originalKeys = data.keys || []; + for (const key of originalKeys) { + if (String(key.user || '').trim().toLowerCase() === normalizedUser && key.public_key) removedRepoKeys.push(key.public_key); + } + const nextKeys = originalKeys.filter((key) => String(key.user || '').trim().toLowerCase() !== normalizedUser); + const nextInvites = (data.invites || []).filter((invite) => String(invite.user || '').trim().toLowerCase() !== normalizedUser); + if (nextKeys.length !== (data.keys || []).length) { + data.keys = nextKeys; + changed = true; + } + if (nextInvites.length !== (data.invites || []).length) { + data.invites = nextInvites; + changed = true; + } + } + if (changed) await doc.ref.set(data, {merge: false}); + } + for (const publicKey of removedRepoKeys) { + const idx = await members.doc(memberDocID(keyFingerprint(publicKey))).collection('repos').get(); + await Promise.all(idx.docs.map((doc) => doc.ref.delete())); + } + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/broker/users/invite/create' && req.method === 'POST') { + await requireBrokerAdmin(req); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || '').trim(); + if (!username) throw new Error('user is required'); + const role = normalizeBrokerRole(body.broker_role || body.role || 'user'); + if (!validBrokerRole(body.broker_role || body.role || 'user') || role === 'owner') throw new Error('invalid broker role'); + users.data.invites = (users.data.invites || []).filter((invite) => Date.parse(invite.expires_at || '') > Date.now()); + const normalizedUser = username.toLowerCase(); + if (users.data.invites.some((invite) => String(invite.user || '').trim().toLowerCase() === normalizedUser)) throw Object.assign(new Error('broker user invite already pending for user'), {status: 409}); + let user = (users.data.users || []).find((item) => String(item.username || '').toLowerCase() === normalizedUser); + if (!user) { + user = {id: 'u_' + crypto.randomBytes(6).toString('hex'), username, broker_role: role, keys: [], suspended: false, pending: true}; + users.data.users.push(user); + } else { + user.username = username; + user.broker_role = role; + if (!(user.keys || []).length) user.pending = true; + } + const token = crypto.randomBytes(24).toString('base64url'); + const brokerURL = String(body.broker_url || '').trim(); + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + users.data.invites.push({token_hash: ownershipTransferTokenHash(token), user: username, role, broker_url: brokerURL, expires_at: expires}); + await saveBrokerUsers(users); + const code = brokerUserInviteCode(brokerURL, username, role, token); + res.status(200).send(JSON.stringify({ok: true, code, accept_command: 'bgit admin accept-broker-invite ' + code, user: username, role})); + return; + } + if (req.path === '/broker/users/invite/accept' && req.method === 'POST') { + const users = await loadBrokerUsers(); + const signed = submittedSignedKey(req); + if (!signed) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const tokenHash = ownershipTransferTokenHash(body.token); + const invites = users.data.invites || []; + const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || '') > Date.now()); + if (!invite) throw Object.assign(new Error('broker user invite is not pending or has expired'), {status: 404}); + const username = String(invite.user || body.user || '').trim(); + let user = (users.data.users || []).find((item) => String(item.username || '').toLowerCase() === username.toLowerCase()); + if (!user) { + user = {id: 'u_' + crypto.randomBytes(6).toString('hex'), username, broker_role: invite.role || 'user', keys: [], suspended: false}; + users.data.users.push(user); + } + user.username = username; + user.broker_role = invite.role || user.broker_role || 'user'; + user.pending = false; + user.keys = user.keys || []; + if (!assertUniqueBrokerUserKey(users, signed.public_key, username)) user.keys.push({public_key: signed.public_key, source: 'broker-invite'}); + users.data.invites = invites.filter((item) => item.token_hash !== tokenHash); + await saveBrokerUsers(users); + await syncAllMembershipIndexes(); + res.status(200).send(JSON.stringify({ok: true, user: username, role: user.broker_role, fingerprint: signed.fingerprint})); + return; + } + if (req.path === '/broker/users/invite/cancel' && req.method === 'POST') { + await requireBrokerAdmin(req); + const users = await loadBrokerUsers(); + const username = String(body.user || body.username || '').trim().toLowerCase(); + const invites = users.data.invites || []; + const next = invites.filter((item) => String(item.user || '').trim().toLowerCase() !== username); + if (next.length === invites.length) throw Object.assign(new Error('broker user invite is not pending or has expired'), {status: 404}); + users.data.invites = next; + users.data.users = (users.data.users || []).filter((item) => { + if (String(item.username || '').trim().toLowerCase() !== username) return true; + return (item.keys || []).length > 0 || !item.pending; + }); + await saveBrokerUsers(users); + await syncAllMembershipIndexes(); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/teams/create' && req.method === 'POST') { + const actor = await requireBrokerAdmin(req); + const name = String(body.name || '').trim(); + if (!name) throw new Error('team name is required'); + const id = body.id || body.team_id || ('t_' + crypto.randomBytes(6).toString('hex')); + const ref = repos.doc(teamDocID(id)); + const snap = await ref.get(); + if (snap.exists) throw Object.assign(new Error('team already exists'), {status: 409}); + const team = {id, name, members: [], created_by: actor.user || '', created_at: new Date().toISOString()}; + await ref.set(team, {merge: false}); + res.status(200).send(JSON.stringify({ok: true, team})); + return; + } + if (req.path === '/teams/delete' && req.method === 'POST') { + await requireBrokerAdmin(req); + const teamID = String(body.team_id || body.id || body.name || '').trim(); + if (!teamID) throw new Error('team_id is required'); + const team = await loadTeam(teamID); + if (!team) throw Object.assign(new Error('team not found'), {status: 404}); + if (team.data.id === coreTeamID || String(team.data.name || '').trim().toLowerCase() === coreTeamName) { + throw Object.assign(new Error('core team cannot be deleted'), {status: 403}); + } + const snap = await repos.get(); + const updates = []; + snap.forEach((doc) => { + if (String(doc.id || '').startsWith('_') || String(doc.id || '').startsWith('team:')) return; + const data = doc.data() || {}; + const teams = data.teams || []; + const next = teams.filter((item) => (item.id || item.team_id) !== teamID); + if (next.length !== teams.length) { + data.teams = next; + updates.push(saveRepo({ref: doc.ref, data})); + } + }); + updates.push(team.ref.delete()); + await Promise.all(updates); + res.status(200).send(JSON.stringify({ok: true})); + return; + } + if (req.path === '/teams/resolve' && req.method === 'POST') { + const team = await loadTeamByName(body.name || body.team || body.team_name); + if (!team) throw Object.assign(new Error('team not found'), {status: 404}); + res.status(200).send(JSON.stringify({team: team.data})); + return; + } + if (req.path === '/teams/list' && req.method === 'POST') { + await requireBrokerAdmin(req); + const snap = await repos.get(); + res.status(200).send(JSON.stringify({teams: snap.docs.filter((doc) => String(doc.id || '').startsWith('team:')).map((doc) => doc.data() || {})})); + return; + } + if (req.path === '/teams/member/upsert' && req.method === 'POST') { + await requireBrokerAdmin(req); + const team = await loadTeam(body.team_id); + if (!team) throw Object.assign(new Error('team not found'), {status: 404}); + const userID = String(body.user_id || '').trim(); + const username = String(body.user || body.username || '').trim(); + const role = normalizeRole(body.role || 'read'); + if (!validRole(role)) throw new Error('invalid role'); + if (!userID && !username) throw new Error('user is required'); + let resolvedUserID = userID; + if (!resolvedUserID && username) { + const users = await loadBrokerUsers(); + const user = (users.data.users || []).find((item) => String(item.username || '').toLowerCase() === username.toLowerCase()); + if (!user) throw Object.assign(new Error('broker user not found'), {status: 404}); + resolvedUserID = user.id; + } + team.data.members = (team.data.members || []).filter((item) => item.user_id !== resolvedUserID && String(item.username || '').toLowerCase() !== username.toLowerCase()); + team.data.members.push({user_id: resolvedUserID, username, role, suspended: false}); + await team.ref.set(team.data, {merge: false}); + await syncReposForTeam(team.data.id); + res.status(200).send(JSON.stringify({ok: true, team: team.data})); + return; + } + if (req.path === '/teams/member/remove' && req.method === 'POST') { + await requireBrokerAdmin(req); + const team = await loadTeam(body.team_id); + if (!team) throw Object.assign(new Error('team not found'), {status: 404}); + const userID = String(body.user_id || '').trim(); + const username = String(body.user || body.username || '').trim().toLowerCase(); + team.data.members = (team.data.members || []).filter((item) => item.user_id !== userID && String(item.username || '').toLowerCase() !== username); + await team.ref.set(team.data, {merge: false}); + await syncReposForTeam(team.data.id); + res.status(200).send(JSON.stringify({ok: true, team: team.data})); + return; + } + if (req.path === '/repos/create' && req.method === 'POST') { + await requireBrokerAdmin(req); + if (await loadExistingRepo(body.repo)) throw Object.assign(new Error('repository already exists'), {status: 409}); + const entry = await loadRepo(body.repo); + const user = body.admin_user || 'admin'; + const role = normalizeRole(body.role || 'developer'); + if (!validRole(role)) throw new Error('invalid role'); + entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + if (body.repo && body.repo.team_id) { + const team = await loadTeam(body.repo.team_id); + if (!team) throw Object.assign(new Error('team not found'), {status: 404}); + entry.data.teams = entry.data.teams || []; + entry.data.teams.push({id: body.repo.team_id, role}); + } + if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); + for (const publicKey of body.public_keys || []) { + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + } + audit(entry, {type: 'repo_create', user}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix})); + return; + } + if (req.path === '/repos/get' && req.method === 'POST') { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + try { + await requireRead(req, entry); + } catch (err) { + try { + await requireBrokerAdmin(req); + } catch (_) { + throw err; + } + } + res.status(200).send(JSON.stringify({ok: true, repo: entry.data.repo || body.repo, teams: entry.data.teams || []})); + return; + } if (req.path === '/repos/upsert' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireAdmin(req, entry); const user = body.admin_user || 'admin'; const role = normalizeRole(body.role || 'admin'); if (!validRole(role)) throw new Error('invalid role'); entry.data.repo = {...(entry.data.repo || {}), ...(body.repo || {})}; + if (body.repo && body.repo.team_id && !(entry.data.teams || []).find((t) => (t.id || t.team_id) === body.repo.team_id)) { + const team = await loadTeam(body.repo.team_id); + if (!team) throw Object.assign(new Error('team not found'), {status: 404}); + entry.data.teams = entry.data.teams || []; + entry.data.teams.push({id: body.repo.team_id, role: body.role || 'developer'}); + } if (body.repo && body.repo.logical && !entry.data.repo.bucket) await ensurePhysicalRepo(entry); for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); } audit(entry, {type: 'repo_upsert', user}); await saveRepo(entry); res.status(200).send(JSON.stringify({ok: true, repo: entry.data.repo, bucket_suffix: entry.data.bucket_suffix})); return; } + if (req.path === '/repo/teams/list' && req.method === 'POST') { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireAdmin(req, entry); + res.status(200).send(JSON.stringify({teams: entry.data.teams || []})); + return; + } + if (req.path === '/repo/teams/upsert' && req.method === 'POST') { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireAdmin(req, entry); + const teamID = String(body.team_id || '').trim(); + if (!teamID) throw new Error('team_id is required'); + const team = await loadTeam(teamID); + if (!team) throw Object.assign(new Error('team not found'), {status: 404}); + const role = normalizeRole(body.role || 'read'); + if (!validRole(role)) throw new Error('invalid role'); + entry.data.teams = (entry.data.teams || []).filter((item) => (item.id || item.team_id) !== teamID); + entry.data.teams.push({id: teamID, role}); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, teams: entry.data.teams})); + return; + } + if (req.path === '/repos/list' && req.method === 'POST') { + await requireBrokerAdmin(req); + const snap = await repos.get(); + const out = []; + snap.forEach((doc) => { + if (String(doc.id || '').startsWith('_') || String(doc.id || '').startsWith('team:')) return; + const data = doc.data() || {}; + const repo = data.repo || {}; + if (!repo.logical) return; + out.push({repo, logical: repo.logical, teams: data.teams || []}); + }); + out.sort((a, b) => String(a.logical || '').localeCompare(String(b.logical || ''))); + res.status(200).send(JSON.stringify({repos: out})); + return; + } + if (req.path === '/repo/teams/remove' && req.method === 'POST') { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireAdmin(req, entry); + const teamID = String(body.team_id || '').trim(); + entry.data.teams = (entry.data.teams || []).filter((item) => (item.id || item.team_id) !== teamID); + await saveRepo(entry); + res.status(200).send(JSON.stringify({ok: true, teams: entry.data.teams || []})); + return; + } if (req.path === '/repo/info' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireRead(req, entry); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireRead(req, entry); res.status(200).send(JSON.stringify({ repo: entry.data.repo || body.repo, description: entry.data.description || '', @@ -766,7 +1375,7 @@ exports.broker = async (req, res) => { } if (req.path === '/repo/update' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); if (Object.prototype.hasOwnProperty.call(body, 'description')) entry.data.description = String(body.description || '').trim(); if (Object.prototype.hasOwnProperty.call(body, 'default_branch')) entry.data.default_branch = String(body.default_branch || '').trim() || 'main'; if (Object.prototype.hasOwnProperty.call(body, 'visibility')) entry.data.visibility = body.visibility === 'public' ? 'public' : 'private'; @@ -787,8 +1396,7 @@ exports.broker = async (req, res) => { } if (req.path === '/repo/rename' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const key = await requireOwner(req, entry); const logical = normalizeLogicalRepo(body.logical); const newRepo = {...(entry.data.repo || body.repo), logical}; const newRef = repos.doc(docID(newRepo)); @@ -809,8 +1417,7 @@ exports.broker = async (req, res) => { } if (req.path === '/repo/delete' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + await requireOwner(req, entry); const repo = await ensurePhysicalRepo(entry); await deletePhysicalRepo(repo); await deleteRepoMetadata(entry); @@ -819,26 +1426,27 @@ exports.broker = async (req, res) => { } if (req.path === '/keys/list' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); res.status(200).send(JSON.stringify({keys: entry.data.keys})); return; } if (req.path === '/keys/add' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); const user = body.user || 'admin'; const role = normalizeRole(body.role || 'read'); if (!validRole(role) || role === 'owner') throw new Error('invalid role'); for (const publicKey of body.public_keys || []) { - if (!entry.data.keys.find((k) => normalizeKey(k.public_key) === normalizeKey(publicKey))) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); + if (!assertUniqueRepoKey(entry, publicKey, user)) entry.data.keys.push({user, role, public_key: publicKey, source: body.source || '', suspended: false}); } await saveRepo(entry); res.status(200).send(JSON.stringify({ok: true})); return; } if (req.path === '/keys/invite/create' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireAdmin(req, entry); const user = String(body.user || '').trim(); const role = normalizeRole(body.role || 'read'); if (!user) throw new Error('user is required'); @@ -856,6 +1464,16 @@ exports.broker = async (req, res) => { res.status(200).send(JSON.stringify({ok: true, code, accept_command: 'bgit admin accept-invite ' + code})); return; } + if (req.path === '/keys/invite/list' && req.method === 'POST') { + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireAdmin(req, entry); + entry.data.invites = (entry.data.invites || []).filter((invite) => Date.parse(invite.expires_at || '') > Date.now()); + await saveRepo(entry); + const invites = (entry.data.invites || []).map((invite) => ({user: invite.user || '', role: invite.role || 'read', expires_at: invite.expires_at || ''})); + res.status(200).send(JSON.stringify({invites})); + return; + } if (req.path === '/keys/invite/accept' && req.method === 'POST') { const entry = await ensureRepo(body.repo); const signed = submittedSignedKey(req); @@ -864,7 +1482,7 @@ exports.broker = async (req, res) => { const invites = entry.data.invites || []; const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || '') > Date.now()); if (!invite) throw Object.assign(new Error('invite is not pending or has expired'), {status: 404}); - const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(signed.public_key)); + const existing = assertUniqueRepoKey(entry, signed.public_key, invite.user); if (existing) { existing.user = invite.user; existing.role = invite.role; @@ -880,8 +1498,9 @@ exports.broker = async (req, res) => { return; } if (req.path === '/keys/invite/cancel' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + await requireAdmin(req, entry); const invites = entry.data.invites || []; const user = String(body.user || '').trim().toLowerCase(); const tokenHash = body.token ? ownershipTransferTokenHash(body.token) : ''; @@ -898,7 +1517,7 @@ exports.broker = async (req, res) => { } if ((req.path === '/keys/remove' || req.path === '/keys/suspend') && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); const key = String(body.key || '').trim(); const match = (k) => keyMatches(k, key); if (entry.data.keys.some((k) => match(k) && k.role === 'owner')) throw Object.assign(new Error('owners cannot be removed or suspended'), {status: 403}); @@ -922,7 +1541,7 @@ exports.broker = async (req, res) => { } if (req.path === '/keys/unsuspend' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); const key = String(body.key || '').trim(); const match = (k) => keyMatches(k, key); let changed = false; @@ -938,9 +1557,9 @@ exports.broker = async (req, res) => { return; } if (req.path === '/owners/transfer/confirm' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + const key = await requireOwner(req, entry); if (entry.data.owner_transfer && !ownershipTransferExpired(entry.data.owner_transfer)) { throw Object.assign(new Error('ownership transfer already pending; run bgit admin cancel-ownership-transfer to cancel it'), {status: 409}); } @@ -961,9 +1580,9 @@ exports.broker = async (req, res) => { return; } if (req.path === '/owners/transfer/cancel' && req.method === 'POST') { - const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); - if (!key || key.role !== 'owner') throw Object.assign(new Error('owner SSH signature required'), {status: 403}); + const entry = await loadExistingRepo(body.repo); + if (!entry) throw Object.assign(new Error('repository not found'), {status: 404}); + const key = await requireOwner(req, entry); delete entry.data.owner_transfer; audit(entry, {type: 'owner_transfer_cancel', user: key.user || ''}); await saveRepo(entry); @@ -982,7 +1601,7 @@ exports.broker = async (req, res) => { for (const item of entry.data.keys || []) { if (item.role === 'owner' && keyFingerprint(item.public_key) === ownerFingerprint) item.role = 'admin'; } - const existing = (entry.data.keys || []).find((item) => normalizeKey(item.public_key) === normalizeKey(accepted.public_key)); + const existing = assertUniqueRepoKey(entry, accepted.public_key, user); if (existing) { existing.role = 'owner'; existing.user = user; @@ -999,13 +1618,13 @@ exports.broker = async (req, res) => { } if (req.path === '/protection/list' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); res.status(200).send(JSON.stringify({protections: entry.data.protections || []})); return; } if (req.path === '/protection/upsert' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); entry.data.protections.push({ref: body.ref, require_pr: body.require_pr !== false, allow_overrides: !!body.allow_overrides}); audit(entry, {type: 'protection_upsert', ref: body.ref}); @@ -1015,7 +1634,7 @@ exports.broker = async (req, res) => { } if (req.path === '/protection/remove' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); entry.data.protections = (entry.data.protections || []).filter((p) => p.ref !== body.ref); audit(entry, {type: 'protection_remove', ref: body.ref}); await saveRepo(entry); @@ -1024,7 +1643,7 @@ exports.broker = async (req, res) => { } if (req.path === '/issues/list' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireRead(req, entry); + await requireRead(req, entry); if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); const issues = await listIssues(entry); res.status(200).send(JSON.stringify({issues})); @@ -1032,7 +1651,7 @@ exports.broker = async (req, res) => { } if (req.path === '/issues/view' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireRead(req, entry); + await requireRead(req, entry); if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); @@ -1042,7 +1661,7 @@ exports.broker = async (req, res) => { if (req.path === '/issues/create' && req.method === 'POST') { const entry = await ensureRepo(body.repo); if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); - const key = requireIssueCreate(req, entry); + const key = await requireIssueCreate(req, entry); const title = String(body.title || '').trim(); const issueBody = String(body.body || '').trim(); if (!title) throw new Error('issue title is required'); @@ -1055,7 +1674,7 @@ exports.broker = async (req, res) => { if (req.path === '/issues/comment' && req.method === 'POST') { const entry = await ensureRepo(body.repo); if (entry.data.issues_enabled === false) throw Object.assign(new Error('issues are disabled'), {status: 403}); - const key = requireIssueCreate(req, entry); + const key = await requireIssueCreate(req, entry); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); const comment = String(body.comment || '').trim(); @@ -1069,7 +1688,7 @@ exports.broker = async (req, res) => { } if (req.path === '/issues/close' || req.path === '/issues/reopen') { const entry = await ensureRepo(body.repo); - requireWrite(req, entry); + await requireWrite(req, entry); const issue = await loadIssue(entry, body.id); if (!issue) throw Object.assign(new Error('issue not found'), {status: 404}); issue.status = req.path === '/issues/reopen' ? 'open' : 'closed'; @@ -1080,7 +1699,7 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/create' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = requireWrite(req, entry); + const key = await requireWrite(req, entry); entry.data.refs = entry.data.refs || {}; const pr = {...(body.pr || {})}; pr.id = nextPRID(entry.data); @@ -1098,19 +1717,19 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/list' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireRead(req, entry); + await requireRead(req, entry); res.status(200).send(JSON.stringify({prs: await listPRs(entry)})); return; } if (req.path === '/prs/sync' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireRead(req, entry); + await requireRead(req, entry); res.status(200).send(JSON.stringify(await syncPRRecords(entry, body.known || {}))); return; } if (req.path === '/prs/view' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireRead(req, entry); + await requireRead(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); res.status(200).send(JSON.stringify({pr})); @@ -1118,7 +1737,7 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/close' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = requireWrite(req, entry); + const key = await requireWrite(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); pr.status = 'closed'; @@ -1133,7 +1752,7 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/reopen' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = requireWrite(req, entry); + const key = await requireWrite(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); pr.status = 'open'; @@ -1149,7 +1768,7 @@ exports.broker = async (req, res) => { if (req.path === '/prs/comment' && req.method === 'POST') { const entry = await ensureRepo(body.repo); if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); - const key = requireRead(req, entry); + const key = await requireRead(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); const comment = String(body.comment || '').trim(); @@ -1166,7 +1785,7 @@ exports.broker = async (req, res) => { if (req.path === '/prs/reply' && req.method === 'POST') { const entry = await ensureRepo(body.repo); if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); - const key = requireRead(req, entry); + const key = await requireRead(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); const comment = String(body.comment || '').trim(); @@ -1184,7 +1803,7 @@ exports.broker = async (req, res) => { } if (req.path === '/prs/review' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = requireWrite(req, entry); + const key = await requireWrite(req, entry); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); const state = String(body.review || '').trim(); @@ -1203,7 +1822,7 @@ exports.broker = async (req, res) => { if (req.path === '/prs/merge' && req.method === 'POST') { const entry = await ensureRepo(body.repo); if (repoIsReadOnly(entry)) throw Object.assign(new Error('repository is read-only'), {status: 403}); - const key = signedKey(req, entry); + const key = await effectiveSignedKey(req, entry); if (!key || !roleAllows(key.role, 'merge')) throw Object.assign(new Error('merge SSH signature required'), {status: 403}); const pr = await loadPR(entry, body.id); if (!pr) throw Object.assign(new Error('pull request not found'), {status: 404}); @@ -1232,7 +1851,7 @@ exports.broker = async (req, res) => { } if (req.path === '/auth/check' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry); + const key = await effectiveSignedKey(req, entry); const operation = body.operation || ''; const allowed = (operation === 'read' && repoIsPublic(entry)) || (!!key && roleAllows(key.role, operation)); res.status(200).send(JSON.stringify({allowed, user: key && key.user || (allowed ? 'anonymous' : ''), role: key && key.role || (allowed ? 'read' : '')})); @@ -1240,15 +1859,25 @@ exports.broker = async (req, res) => { } if (req.path === '/auth/status' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = signedKey(req, entry) || (repoIsPublic(entry) ? anonymousKey() : null); + let key = await effectiveSignedKey(req, entry) || (repoIsPublic(entry) ? anonymousKey() : null); + const brokerAdmin = await requireBrokerAdmin(req).catch(() => null); + if (!key && brokerAdmin) { + key = {user: brokerAdmin.user || '', role: '', broker_role: brokerAdmin.broker_role || 'admin', public_key: brokerAdmin.public_key || '', source: 'broker-admin'}; + } if (!key) throw Object.assign(new Error('SSH signature required'), {status: 403}); + const capabilities = roleCapabilities(key.role || ''); + if (brokerAdmin) { + capabilities.admin_keys = true; + capabilities.manage_protection = true; + capabilities.broker_upgrade = true; + } res.status(200).send(JSON.stringify({ broker_version: brokerVersion, repo: entry.data.repo || body.repo, identity: {user: key.user || '', source: key.source || '', key_fingerprint: key.public_key ? keyFingerprint(key.public_key) : '', public_key: key.public_key || ''}, user: key.user || '', role: key.role || '', - capabilities: roleCapabilities(key.role || ''), + capabilities, resolved_at: new Date().toISOString(), })); return; @@ -1269,7 +1898,7 @@ exports.broker = async (req, res) => { if (req.path === '/members/reindex' && req.method === 'POST') { if (body.repo && (body.repo.logical || body.repo.bucket || body.repo.prefix)) { const entry = await ensureRepo(body.repo); - requireAdmin(req, entry); + await requireAdmin(req, entry); await syncMembershipIndex(entry); res.status(200).send(JSON.stringify({ok: true, repositories: 1})); return; @@ -1283,7 +1912,7 @@ exports.broker = async (req, res) => { if (req.path === '/objects/capability' && req.method === 'POST') { const entry = await ensureRepo(body.repo); const operation = body.operation || 'read'; - const key = operation === 'read' ? requireRead(req, entry) : requireWrite(req, entry); + const key = operation === 'read' ? await requireRead(req, entry) : await requireWrite(req, entry); const repo = await ensurePhysicalRepo(entry); const capability = body.resumable ? await resumableUpload(repo, body.path) : await signedURL(repo, body.path, operation); audit(entry, {type: 'capability_issued', operation, path: body.path, user: key.user, role: key.role}); @@ -1293,7 +1922,7 @@ exports.broker = async (req, res) => { } if (req.path === '/objects/read' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireRead(req, entry); + await requireRead(req, entry); const repo = await ensurePhysicalRepo(entry); const data = await readObject(repo, body.path); res.status(200).send(JSON.stringify({data})); @@ -1301,7 +1930,7 @@ exports.broker = async (req, res) => { } if (req.path === '/objects/list' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - requireRead(req, entry); + await requireRead(req, entry); const repo = await ensurePhysicalRepo(entry); const paths = await listObjects(repo, body.prefix); res.status(200).send(JSON.stringify({paths})); @@ -1309,7 +1938,7 @@ exports.broker = async (req, res) => { } if (req.path === '/refs/update' && req.method === 'POST') { const entry = await ensureRepo(body.repo); - const key = requireWrite(req, entry); + const key = await requireWrite(req, entry); await updateRefCAS(body.repo, body.ref, body.old, body.new, key, {override: !!body.override}); res.status(200).send(JSON.stringify({ok: true})); return; diff --git a/broker_commands.go b/broker_commands.go index 64a90b0..a62ba38 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -8,11 +8,13 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" ) @@ -23,6 +25,7 @@ type brokerProfile struct { Region string QualifiedName string BrokerURL string + TeamID string } func brokerAdminCommand(cfg config, args []string, stdout io.Writer) error { @@ -31,9 +34,13 @@ func brokerAdminCommand(cfg config, args []string, stdout io.Writer) error { func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit admin keys|owner|protect|members|confirm-ownership-transfer|accept-ownership-transfer|cancel-ownership-transfer|invite-user|accept-invite|cancel-invite [args]\n\nCloud IAM administration moved to bgit direct admin.") + return errors.New("usage: bgit admin keys|broker-users|teams|repo create|repo|owner|protect|members|confirm-ownership-transfer|accept-ownership-transfer|cancel-ownership-transfer|invite-user|accept-invite|cancel-invite|invite-broker-user|accept-broker-invite|cancel-broker-invite [args]\n\nCloud IAM administration moved to bgit direct admin.") } switch args[0] { + case "broker-users": + return brokerUsersCommand(cfg, args[1:], stdout) + case "teams": + return brokerTeamsCommand(cfg, args[1:], stdout) case "keys": return brokerAdminKeysCommand(cfg, args[1:], stdin, stdout) case "repo": @@ -52,6 +59,12 @@ func brokerAdminCommandWithInput(cfg config, args []string, stdin io.Reader, std return brokerAcceptInviteCommand(args[1:], stdout) case "cancel-invite": return brokerCancelInviteCommand(cfg, args[1:], stdout) + case "invite-broker-user": + return brokerInviteBrokerUserCommand(cfg, args[1:], stdout) + case "accept-broker-invite": + return brokerAcceptBrokerInviteCommand(args[1:], stdout) + case "cancel-broker-invite": + return brokerCancelBrokerInviteCommand(cfg, args[1:], stdout) case "grant-read", "grant-write", "grant-admin", "make-public", "make-private": return errors.New("cloud IAM administration moved to bgit direct admin") default: @@ -67,17 +80,373 @@ type brokerRepoAdminRequest struct { ReadOnly *bool `json:"read_only,omitempty"` IssuesEnabled *bool `json:"issues_enabled,omitempty"` Logical string `json:"logical,omitempty"` + TeamID string `json:"team_id,omitempty"` + Name string `json:"name,omitempty"` + UserID string `json:"user_id,omitempty"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + BrokerRole string `json:"broker_role,omitempty"` + PublicKeys []string `json:"public_keys,omitempty"` + Suspended bool `json:"suspended,omitempty"` + BrokerURL string `json:"broker_url,omitempty"` + Token string `json:"token,omitempty"` +} + +type brokerRepoListResponse struct { + Repos []brokerRepoInfo `json:"repos"` +} + +type brokerRepoInfo struct { + Repo brokerRepo `json:"repo"` + Logical string `json:"logical,omitempty"` + Teams []brokerRepoTeamGrant `json:"teams,omitempty"` +} + +type brokerAdminRepoInfoResponse struct { + Repo brokerRepo `json:"repo"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Visibility string `json:"visibility,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + IssuesEnabled bool `json:"issues_enabled,omitempty"` +} + +type brokerRepoTeamsResponse struct { + Teams []brokerRepoTeamGrant `json:"teams"` +} + +type brokerUsersResponse struct { + Users []brokerUserInfo `json:"users"` +} + +type brokerUserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + BrokerRole string `json:"broker_role"` + Keys []brokerKey `json:"keys,omitempty"` + Suspended bool `json:"suspended,omitempty"` + Pending bool `json:"pending,omitempty"` +} + +type brokerRepoInvitesResponse struct { + Invites []brokerRepoInviteInfo `json:"invites"` +} + +type brokerRepoInviteInfo struct { + User string `json:"user"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` +} + +type brokerTeamsResponse struct { + Teams []brokerTeamInfo `json:"teams"` +} + +type brokerTeamInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Members []brokerTeamMember `json:"members,omitempty"` +} + +type brokerTeamMember struct { + UserID string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + Role string `json:"role"` +} + +func brokerUsersCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return err + } + if len(args) == 1 && args[0] == "list" { + var resp brokerUsersResponse + if err := brokerPost(brokerURL, "/broker/users/list", brokerRepoAdminRequest{}, &resp); err != nil { + return err + } + printBrokerUsers(stdout, resp.Users) + return nil + } + if len(args) == 2 && args[0] == "delete" { + req := brokerRepoAdminRequest{User: args[1]} + if err := brokerPost(brokerURL, "/broker/users/delete", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "deleted broker user %s\n", req.User) + return nil + } + if len(args) >= 2 && args[0] == "upsert" { + req := brokerRepoAdminRequest{User: args[1], BrokerRole: "user"} + for i := 2; i < len(args); i++ { + name, value, hasValue := strings.Cut(args[i], "=") + switch name { + case "--role": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + req.BrokerRole = strings.TrimSpace(value) + case "--key": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + keys, err := publicKeysFromArg(value) + if err != nil { + return err + } + req.PublicKeys = append(req.PublicKeys, keys...) + case "--suspended": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + req.Suspended, err = strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return fmt.Errorf("invalid --suspended value %q", value) + } + default: + return fmt.Errorf("unsupported broker-users upsert option %s", args[i]) + } + } + var resp struct { + User brokerUserInfo `json:"user"` + } + if !validBrokerUserRole(req.BrokerRole) { + return fmt.Errorf("invalid broker role %q", req.BrokerRole) + } + if err := brokerPost(brokerURL, "/broker/users/upsert", req, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "upserted broker user %s as %s\n", resp.User.Username, resp.User.BrokerRole) + return nil + } + return errors.New("usage: bgit admin broker-users list | upsert USER [--role admin|user] [--key PATH_OR_PUBLIC_KEY] [--suspended true|false] | delete USER") +} + +func printBrokerUsers(stdout io.Writer, users []brokerUserInfo) { + fmt.Fprintf(stdout, "%-18s %-28s %-8s %-9s\n", "ID", "Username", "Role", "Status") + fmt.Fprintf(stdout, "%-18s %-28s %-8s %-9s\n", strings.Repeat("-", 2), strings.Repeat("-", 8), strings.Repeat("-", 4), strings.Repeat("-", 6)) + sort.Slice(users, func(i, j int) bool { + return firstNonEmpty(users[i].Username, users[i].ID) < firstNonEmpty(users[j].Username, users[j].ID) + }) + for _, user := range users { + status := "active" + if user.Suspended { + status = "suspended" + } else if user.Pending || len(user.Keys) == 0 { + status = "pending" + } + fmt.Fprintf(stdout, "%-18s %-28s %-8s %-9s\n", truncateSetupColumn(user.ID, 18), truncateSetupColumn(user.Username, 28), truncateSetupColumn(user.BrokerRole, 8), status) + } +} + +func truncateSetupColumn(value string, width int) string { + value = strings.TrimSpace(value) + if len(value) <= width { + return value + } + if width <= 1 { + return value[:width] + } + return value[:width-1] + "…" +} + +func validBrokerUserRole(role string) bool { + switch strings.TrimSpace(role) { + case "admin", "user": + return true + default: + return false + } +} + +func validRepoRole(role string) bool { + role = normalizeBrokerRole(role) + return validBrokerRole(role) +} + +func brokerTeamsCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return err + } + if len(args) == 1 && args[0] == "list" { + var resp brokerTeamsResponse + if err := brokerPost(brokerURL, "/teams/list", brokerRepoAdminRequest{}, &resp); err != nil { + return err + } + for _, team := range resp.Teams { + members := make([]string, 0, len(team.Members)) + for _, member := range team.Members { + members = append(members, firstNonEmpty(member.Username, member.UserID)+":"+member.Role) + } + memberText := fmt.Sprintf("%d member(s)", len(team.Members)) + if len(members) > 0 { + memberText += "\t" + strings.Join(members, ",") + } + fmt.Fprintf(stdout, "%s\t%s\t%s\n", team.ID, team.Name, memberText) + } + return nil + } + if len(args) >= 2 && args[0] == "create" { + req := brokerRepoAdminRequest{Name: args[1]} + var resp struct { + Team brokerTeamInfo `json:"team"` + } + if err := brokerPost(brokerURL, "/teams/create", req, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "created team %s (%s)\n", resp.Team.Name, resp.Team.ID) + return nil + } + if len(args) == 2 && args[0] == "delete" { + req := brokerRepoAdminRequest{TeamID: args[1]} + if err := brokerPost(brokerURL, "/teams/delete", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "deleted team %s\n", req.TeamID) + return nil + } + if len(args) >= 4 && args[0] == "member" && args[1] == "add" { + req := brokerRepoAdminRequest{TeamID: args[2], User: args[3], Role: "read"} + for i := 4; i < len(args); i++ { + name, value, hasValue := strings.Cut(args[i], "=") + if name != "--role" { + return fmt.Errorf("unsupported teams member add option %s", args[i]) + } + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + req.Role = normalizeBrokerRole(value) + } + if !validRepoRole(req.Role) { + return fmt.Errorf("invalid team member role %q", req.Role) + } + if err := brokerPost(brokerURL, "/teams/member/upsert", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "added %s to team %s as %s\n", req.User, req.TeamID, req.Role) + return nil + } + if len(args) == 4 && args[0] == "member" && args[1] == "remove" { + req := brokerRepoAdminRequest{TeamID: args[2], User: args[3]} + if err := brokerPost(brokerURL, "/teams/member/remove", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "removed %s from team %s\n", req.User, req.TeamID) + return nil + } + if len(args) == 2 && args[0] == "repo" && args[1] == "list" { + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + var resp brokerRepoTeamsResponse + if err := brokerPost(cfg.brokerURL, "/repo/teams/list", brokerRepoAdminRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + for _, team := range resp.Teams { + fmt.Fprintf(stdout, "%s\t%s\n", firstNonEmpty(team.ID, team.TeamID), team.Role) + } + return nil + } + if len(args) >= 4 && args[0] == "repo" && args[1] == "add" { + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + role := normalizeBrokerRole(args[3]) + if !validRepoRole(role) { + return fmt.Errorf("invalid repo team role %q", args[3]) + } + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), TeamID: args[2], Role: role} + if err := brokerPost(cfg.brokerURL, "/repo/teams/upsert", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "attached team %s to %s as %s\n", req.TeamID, cfg.logicalRepo, req.Role) + return nil + } + if len(args) == 3 && args[0] == "repo" && args[1] == "remove" { + cfg, err := configForBrokerCommand(cfg) + if err != nil { + return err + } + req := brokerRepoAdminRequest{Repo: repoForBroker(cfg), TeamID: args[2]} + if err := brokerPost(cfg.brokerURL, "/repo/teams/remove", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "detached team %s from %s\n", req.TeamID, cfg.logicalRepo) + return nil + } + return errors.New("usage: bgit admin teams list|create NAME|delete TEAM|member add TEAM USER [--role ROLE]|member remove TEAM USER|repo list|repo add TEAM ROLE|repo remove TEAM") +} + +func brokerURLFromConfigOrDiscovery(cfg config) (string, error) { + if strings.TrimSpace(cfg.brokerURL) != "" { + return strings.TrimSpace(cfg.brokerURL), nil + } + if local, err := configForBrokerCommand(cfg); err == nil && strings.TrimSpace(local.brokerURL) != "" { + return strings.TrimSpace(local.brokerURL), nil + } + return brokerURLForCommand(sshSetupOptions{}) } func brokerAdminRepoCommand(cfg config, args []string, stdout io.Writer) error { if len(args) == 0 { - return errors.New("usage: bgit admin repo visibility|readonly|issues|rename|delete [args]") + return errors.New("usage: bgit admin repo list|info|create|visibility|readonly|issues|rename|delete [args]") + } + if args[0] == "create" { + return brokerAdminRepoCreateCommand(cfg, args[1:], stdout) + } + if args[0] == "list" { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return err + } + var resp brokerRepoListResponse + if err := brokerPost(brokerURL, "/repos/list", brokerRepoAdminRequest{}, &resp); err != nil { + return err + } + for _, repo := range resp.Repos { + teamIDs := make([]string, 0, len(repo.Teams)) + for _, team := range repo.Teams { + teamIDs = append(teamIDs, firstNonEmpty(team.ID, team.TeamID)) + } + sort.Strings(teamIDs) + fmt.Fprintf(stdout, "%s\t%s\n", logicalRepoDisplayName(firstNonEmpty(repo.Logical, repo.Repo.Logical)), strings.Join(teamIDs, ",")) + } + return nil } cfg, err := configForBrokerCommand(cfg) if err != nil { return err } switch args[0] { + case "info": + if len(args) != 1 { + return errors.New("usage: bgit admin repo info") + } + var resp brokerAdminRepoInfoResponse + if err := brokerPost(cfg.brokerURL, "/repo/info", brokerRepoAdminRequest{Repo: repoForBroker(cfg)}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "repository: %s\n", logicalRepoDisplayName(resp.Repo.Logical)) + fmt.Fprintf(stdout, "visibility: %s\n", firstNonEmpty(resp.Visibility, "private")) + fmt.Fprintf(stdout, "read-only: %t\n", resp.ReadOnly) + fmt.Fprintf(stdout, "issues: %t\n", resp.IssuesEnabled) + if resp.DefaultBranch != "" { + fmt.Fprintf(stdout, "default branch: %s\n", resp.DefaultBranch) + } + if resp.Description != "" { + fmt.Fprintf(stdout, "description: %s\n", resp.Description) + } + return nil case "visibility": if len(args) != 2 || (args[1] != "public" && args[1] != "private") { return errors.New("usage: bgit admin repo visibility public|private") @@ -139,6 +508,72 @@ func brokerAdminRepoCommand(cfg config, args []string, stdout io.Writer) error { } } +func brokerAdminRepoCreateCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL := strings.TrimSpace(cfg.brokerURL) + team := "" + role := "developer" + repoName := "" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + brokerURL = strings.TrimSpace(value) + case "--team": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + team = strings.TrimSpace(value) + case "--role": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + role = normalizeBrokerRole(value) + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported repo create option %s", arg) + } + if repoName != "" { + return errors.New("repo create accepts exactly one repository") + } + repoName = strings.TrimSpace(arg) + } + } + if brokerURL == "" { + brokerURL, err = brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return err + } + } + if repoName == "" || team == "" { + return errors.New("usage: bgit admin repo create --team TEAM [--role ROLE] [--broker URL] REPO") + } + if !validRepoRole(role) { + return fmt.Errorf("invalid repo team role %q", role) + } + teamID, err := resolveBrokerTeamName(brokerURL, team) + if err != nil { + return err + } + repo, err := brokerRepoForAdminTarget(cfg, repoName, teamID) + if err != nil { + return err + } + req := brokerRepoAdminRequest{Repo: repo, Role: role} + if err := brokerPost(brokerURL, "/repos/create", req, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "created repository %s in team %s\n", logicalRepoDisplayName(repo.Logical), team) + return nil +} + func brokerMembersCommand(cfg config, args []string, stdout io.Writer) error { if len(args) != 1 || args[0] != "reindex" { return errors.New("usage: bgit admin members reindex") @@ -192,6 +627,9 @@ func brokerInitCommand(args []string, stdin io.Reader, stdout io.Writer) error { if strings.TrimSpace(repoName) == "" { return errors.New("init --noninteractive requires --repo NAME") } + if strings.TrimSpace(opts.team) == "" { + return errors.New("init --noninteractive requires --team TEAM") + } } global, path, err := loadGlobalConfigForInit(opts.configPath) if err != nil { @@ -260,6 +698,21 @@ func brokerInitCommand(args []string, stdin io.Reader, stdout io.Writer) error { if err != nil { return err } + teamID := strings.TrimSpace(opts.team) + if opts.interactive && teamID == "" { + teamID, err = brokerInitSelectTeam(stdin, stdout, profile) + if err != nil { + return err + } + } + if teamID == "" { + return errors.New("init requires --team TEAM") + } + teamID, err = resolveBrokerTeamName(profile.BrokerURL, teamID) + if err != nil { + return err + } + profile.TeamID = teamID if identityName == "" && identityEmail == "" { identity := initDialogInitialState(target, global, repoName, opts.profile) identityName = identity.IdentityName @@ -273,24 +726,44 @@ func brokerCloneCommand(args []string, stdin io.Reader, stdout io.Writer) error if err != nil { return err } + discoveredTeamID := "" if opts.brokerURL == "" { - brokerURL, parsedRepo, ok, err := parseBrokerCloneURL(repoName) + brokerURL, parsedRepo, teamID, ok, err := discoverBrokerCloneURL(repoName) if err != nil { return err } if ok { opts.brokerURL = brokerURL repoName = parsedRepo + discoveredTeamID = teamID + } + } + if opts.brokerURL == "" { + brokerURL, parsedRepo, teamName, ok, err := parseBrokerCloneURL(repoName) + if err != nil { + return err + } + if ok { + opts.brokerURL = brokerURL + repoName = parsedRepo + if teamName != "" { + teamID, err := resolveBrokerTeamName(brokerURL, teamName) + if err != nil { + return err + } + discoveredTeamID = teamID + } } } if strings.TrimSpace(repoName) == "" { - return errors.New("usage: bgit clone [directory] [--profile PROFILE]\n bgit clone https://broker.example.com/app.git [directory]\n bgit clone --broker https://broker.example.com app.git [directory]") + return errors.New("usage: bgit clone [directory] [--profile PROFILE]\n bgit clone https://broker.example.com/app.git [directory]\n bgit clone https://broker.example.com/team/app.git [directory]\n bgit clone https://broker.example.com/team/app/app.git [directory]\n bgit clone --broker https://broker.example.com app.git [directory]") } if opts.brokerURL != "" { profile, err := brokerProfileForCloneURL(opts.brokerURL) if err != nil { return err } + profile.TeamID = discoveredTeamID return brokerCloneWithProfile(opts, repoName, profile, stdout) } global, _, err := loadGlobalConfigForInit(opts.configPath) @@ -321,6 +794,9 @@ func brokerCloneWithProfile(opts brokerInitOptions, repoName string, profile bro if target == "" { target = strings.TrimSuffix(filepath.Base(strings.Trim(repoName, "/")), ".git") } + if strings.TrimSpace(profile.TeamID) == "" { + profile.TeamID = coreTeamID + } if err := initBrokerWorktree(target, repoName, profile, "", "", io.Discard); err != nil { return err } @@ -443,6 +919,17 @@ func fetchGitHubPublicKeys(ctx context.Context, username string) ([]string, erro return splitPublicKeyLines(string(data)), nil } +func publicKeysFromArg(value string) ([]string, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, errors.New("public key is required") + } + if data, err := os.ReadFile(expandHome(value)); err == nil { + return splitPublicKeyLines(string(data)), nil + } + return splitPublicKeyLines(value), nil +} + func configForBrokerCommand(base config) (config, error) { cfg := base if localCfg, err := readLocalConfig("."); err == nil { @@ -498,6 +985,8 @@ type ownerTransferCodePayload struct { BrokerURL string `json:"broker_url"` Repo brokerRepo `json:"repo"` Token string `json:"token"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` } func brokerOwnerCommand(cfg config, args []string, stdout io.Writer) error { @@ -532,7 +1021,7 @@ func brokerConfirmOwnershipTransferCommand(cfg config, args []string, stdout io. if err := brokerPost(brokerURL, "/owners/transfer/confirm", brokerOwnerTransferRequest{Repo: repo, BrokerURL: brokerURL}, &resp); err != nil { return err } - fmt.Fprintf(stdout, "ownership transfer pending for %s\n\nGive this command to the new owner:\n %s\n\nCancel with:\n %s\n", repo.Logical, resp.AcceptCommand, resp.CancelCommand) + fmt.Fprintf(stdout, "ownership transfer pending for %s\n\nCode:\n %s\n\nGive this command to the new owner:\n %s\n\nCancel with:\n %s\n", repo.Logical, resp.Code, resp.AcceptCommand, resp.CancelCommand) return nil } @@ -633,6 +1122,7 @@ func parseOwnershipTransferCode(code string) (ownerTransferCodePayload, error) { func brokerInviteUserCommand(cfg config, args []string, stdout io.Writer) error { brokerURL := "" repoName := "" + teamID := "" user := "" role := "read" var err error @@ -658,6 +1148,12 @@ func brokerInviteUserCommand(cfg config, args []string, stdout io.Writer) error return err } role = normalizeBrokerRole(value) + case "--team": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + teamID = strings.TrimSpace(value) default: if strings.HasPrefix(arg, "-") { return fmt.Errorf("unsupported invite-user option %s", arg) @@ -669,21 +1165,20 @@ func brokerInviteUserCommand(cfg config, args []string, stdout io.Writer) error } } if brokerURL == "" || repoName == "" || user == "" { - return errors.New("usage: bgit admin invite-user --broker URL --user USER [--role ROLE] REPO") + return errors.New("usage: bgit admin invite-user --broker URL [--team TEAM] --user USER [--role ROLE] REPO") } if !validBrokerRole(role) || role == "owner" { return fmt.Errorf("invalid role %q", role) } - logical, err := normalizeLogicalRepoName(repoName) + repo, err := brokerRepoForAdminTarget(cfg, repoName, teamID) if err != nil { return err } - repo := brokerRepo{Provider: "gcs", Logical: logical, Origin: "git@" + defaultSSHHost + ":" + logical} var resp brokerOwnerTransferResponse if err := brokerPost(brokerURL, "/keys/invite/create", brokerOwnerTransferRequest{Repo: repo, BrokerURL: brokerURL, User: user, Role: role}, &resp); err != nil { return err } - fmt.Fprintf(stdout, "invite pending for %s as %s on %s\n\nGive this command to the user:\n %s\n", user, role, repo.Logical, resp.AcceptCommand) + fmt.Fprintf(stdout, "invite pending for %s as %s on %s\n\nCode:\n %s\n\nGive this command to the user:\n %s\n", user, role, repo.Logical, resp.Code, resp.AcceptCommand) return nil } @@ -704,15 +1199,14 @@ func brokerAcceptInviteCommand(args []string, stdout io.Writer) error { } func brokerCancelInviteCommand(cfg config, args []string, stdout io.Writer) error { - brokerURL, repoName, user, err := parseCancelInviteTarget(cfg, args) + brokerURL, repoName, teamID, user, err := parseCancelInviteTarget(cfg, args) if err != nil { return err } - logical, err := normalizeLogicalRepoName(repoName) + repo, err := brokerRepoForAdminTarget(cfg, repoName, teamID) if err != nil { return err } - repo := brokerRepo{Provider: "gcs", Logical: logical, Origin: "git@" + defaultSSHHost + ":" + logical} if err := brokerPost(brokerURL, "/keys/invite/cancel", brokerOwnerTransferRequest{Repo: repo, User: user}, nil); err != nil { return err } @@ -720,9 +1214,130 @@ func brokerCancelInviteCommand(cfg config, args []string, stdout io.Writer) erro return nil } -func parseCancelInviteTarget(cfg config, args []string) (string, string, string, error) { +func brokerInviteBrokerUserCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL := "" + user := "" + role := "user" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + brokerURL = strings.TrimSpace(value) + case "--user": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + user = strings.TrimSpace(value) + case "--role": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + role = strings.TrimSpace(value) + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported invite-broker-user option %s", arg) + } + if user != "" { + return errors.New("invite-broker-user accepts exactly one username") + } + user = strings.TrimSpace(arg) + } + } + if brokerURL == "" { + var err error + brokerURL, err = brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return err + } + } + if user == "" { + return errors.New("usage: bgit admin invite-broker-user --broker URL --user USER [--role admin|user]") + } + if !validBrokerUserRole(role) { + return fmt.Errorf("invalid broker role %q", role) + } + var resp brokerOwnerTransferResponse + if err := brokerPost(brokerURL, "/broker/users/invite/create", brokerRepoAdminRequest{User: user, BrokerRole: role, BrokerURL: brokerURL}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "broker user invite pending for %s as %s\n\nCode:\n %s\n\nGive this command to the user:\n %s\n", user, role, resp.Code, resp.AcceptCommand) + return nil +} + +func brokerAcceptBrokerInviteCommand(args []string, stdout io.Writer) error { + if len(args) != 1 { + return errors.New("usage: bgit admin accept-broker-invite CODE") + } + payload, err := parseBrokerUserInviteCode(args[0]) + if err != nil { + return err + } + var resp brokerOwnerTransferResponse + if err := brokerPost(payload.BrokerURL, "/broker/users/invite/accept", brokerRepoAdminRequest{User: payload.User, Token: payload.Token}, &resp); err != nil { + return err + } + fmt.Fprintf(stdout, "accepted broker invite for %s as %s with key %s\n", resp.User, resp.Role, resp.Fingerprint) + return nil +} + +func brokerCancelBrokerInviteCommand(cfg config, args []string, stdout io.Writer) error { + brokerURL := "" + user := "" + var err error + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, hasValue := strings.Cut(arg, "=") + switch name { + case "--broker": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + brokerURL = strings.TrimSpace(value) + case "--user": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return err + } + user = strings.TrimSpace(value) + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unsupported cancel-broker-invite option %s", arg) + } + if user != "" { + return errors.New("cancel-broker-invite accepts exactly one username") + } + user = strings.TrimSpace(arg) + } + } + if brokerURL == "" { + brokerURL, err = brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return err + } + } + if user == "" { + return errors.New("usage: bgit admin cancel-broker-invite --broker URL --user USER") + } + if err := brokerPost(brokerURL, "/broker/users/invite/cancel", brokerRepoAdminRequest{User: user}, nil); err != nil { + return err + } + fmt.Fprintf(stdout, "cancelled broker invite for %s\n", user) + return nil +} + +func parseCancelInviteTarget(cfg config, args []string) (string, string, string, string, error) { brokerURL := "" repoName := "" + teamID := "" user := "" var err error for i := 0; i < len(args); i++ { @@ -732,21 +1347,27 @@ func parseCancelInviteTarget(cfg config, args []string) (string, string, string, case "--broker": value, i, err = optionValue(args, i, hasValue, value, name) if err != nil { - return "", "", "", err + return "", "", "", "", err } brokerURL = strings.TrimSpace(value) case "--user": value, i, err = optionValue(args, i, hasValue, value, name) if err != nil { - return "", "", "", err + return "", "", "", "", err } user = strings.TrimSpace(value) + case "--team": + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return "", "", "", "", err + } + teamID = strings.TrimSpace(value) default: if strings.HasPrefix(arg, "-") { - return "", "", "", fmt.Errorf("unsupported cancel-invite option %s", arg) + return "", "", "", "", fmt.Errorf("unsupported cancel-invite option %s", arg) } if repoName != "" { - return "", "", "", errors.New("cancel-invite accepts exactly one repository") + return "", "", "", "", errors.New("cancel-invite accepts exactly one repository") } repoName = strings.TrimSpace(arg) } @@ -762,9 +1383,25 @@ func parseCancelInviteTarget(cfg config, args []string) (string, string, string, } } if brokerURL == "" || repoName == "" || user == "" { - return "", "", "", errors.New("usage: bgit admin cancel-invite --broker URL --user USER REPO") + return "", "", "", "", errors.New("usage: bgit admin cancel-invite --broker URL [--team TEAM] --user USER REPO") } - return brokerURL, repoName, user, nil + return brokerURL, repoName, teamID, user, nil +} + +func brokerRepoForAdminTarget(cfg config, repoName, teamID string) (brokerRepo, error) { + logical, err := normalizeLogicalRepoName(repoName) + if err != nil { + return brokerRepo{}, err + } + local := cfg + local.logicalRepo = logical + local.prefix = logical + local.origin = "git@" + defaultSSHHost + ":" + logical + local.provider = firstNonEmpty(local.provider, "gcs") + if strings.TrimSpace(teamID) != "" { + local.teamID = strings.TrimSpace(teamID) + } + return repoForBroker(local), nil } func parseInviteCode(code string) (ownerTransferCodePayload, error) { @@ -787,6 +1424,26 @@ func parseInviteCode(code string) (ownerTransferCodePayload, error) { return payload, nil } +func parseBrokerUserInviteCode(code string) (ownerTransferCodePayload, error) { + code = strings.TrimSpace(code) + if !strings.HasPrefix(code, "bgituser_") { + return ownerTransferCodePayload{}, errors.New("invalid broker user invite code") + } + raw := strings.TrimPrefix(code, "bgituser_") + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return ownerTransferCodePayload{}, errors.New("invalid broker user invite code") + } + var payload ownerTransferCodePayload + if err := json.Unmarshal(data, &payload); err != nil { + return ownerTransferCodePayload{}, errors.New("invalid broker user invite code") + } + if strings.TrimSpace(payload.BrokerURL) == "" || strings.TrimSpace(payload.Token) == "" || strings.TrimSpace(payload.User) == "" { + return ownerTransferCodePayload{}, errors.New("invalid broker user invite code") + } + return payload, nil +} + type brokerProtectionRequest struct { Repo brokerRepo `json:"repo"` Ref string `json:"ref"` @@ -1280,6 +1937,7 @@ type brokerInitOptions struct { noninteractive bool profile string region string + team string repo string brokerURL string configPath string @@ -1311,6 +1969,13 @@ func parseBrokerInitArgs(args []string) (brokerInitOptions, string, error) { return opts, "", err } opts.region = value + case "--team": + var err error + value, i, err = optionValue(args, i, hasValue, value, name) + if err != nil { + return opts, "", err + } + opts.team = value case "--repo": var err error value, i, err = optionValue(args, i, hasValue, value, name) @@ -1366,33 +2031,146 @@ func parseBrokerInitArgs(args []string) (brokerInitOptions, string, error) { } } -func parseBrokerCloneURL(raw string) (string, string, bool, error) { +func parseBrokerCloneURL(raw string) (string, string, string, bool, error) { raw = strings.TrimSpace(raw) if raw == "" || (!strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://")) { - return "", "", false, nil + return "", "", "", false, nil } parsed, err := url.Parse(raw) if err != nil { - return "", "", true, fmt.Errorf("parse broker clone URL: %w", err) + return "", "", "", true, fmt.Errorf("parse broker clone URL: %w", err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", "", true, fmt.Errorf("unsupported broker clone URL scheme %q", parsed.Scheme) + return "", "", "", true, fmt.Errorf("unsupported broker clone URL scheme %q", parsed.Scheme) } if parsed.Host == "" { - return "", "", true, errors.New("broker clone URL must include a host") + return "", "", "", true, errors.New("broker clone URL must include a host") } if parsed.RawQuery != "" || parsed.Fragment != "" { - return "", "", true, errors.New("broker clone URL must not include query parameters or a fragment") + return "", "", "", true, errors.New("broker clone URL must not include query parameters or a fragment") } - repoName := strings.Trim(parsed.Path, "/") - if repoName == "" { - return "", "", true, errors.New("broker clone URL must include a logical repository path") + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) == 0 || parts[0] == "" { + return "", "", "", true, errors.New("broker clone URL must include a logical repository path") } - logical, err := normalizeLogicalRepoName(repoName) + if len(parts) > 3 { + return "", "", "", true, errors.New("broker clone URL accepts repo.git, team/repo.git, or team/repo/repo.git") + } + teamName := "" + repoPart := parts[len(parts)-1] + if len(parts) >= 2 { + teamName = strings.TrimSpace(parts[0]) + } + logical, err := normalizeLogicalRepoName(repoPart) + if err != nil { + return "", "", "", true, err + } + if len(parts) == 3 && strings.TrimSuffix(parts[1], ".git") != strings.TrimSuffix(logical, ".git") { + return "", "", "", true, errors.New("broker clone URL middle repo segment must match the repository name") + } + return parsed.Scheme + "://" + parsed.Host, logical, teamName, true, nil +} + +var lookupTXT = net.LookupTXT + +func discoverBrokerCloneURL(raw string) (string, string, string, bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" || (!strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://")) { + return "", "", "", false, nil + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "", "", "", false, err + } + if isDirectBrokerHost(parsed.Hostname()) { + return "", "", "", false, nil + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 2 { + return "", "", "", false, nil + } + teamName := strings.TrimSpace(parts[0]) + logical, err := normalizeLogicalRepoName(parts[len(parts)-1]) if err != nil { - return "", "", true, err + return "", "", "", false, err } - return parsed.Scheme + "://" + parsed.Host, logical, true, nil + if len(parts) == 3 && strings.TrimSuffix(parts[1], ".git") != strings.TrimSuffix(logical, ".git") { + return "", "", "", false, nil + } + if len(parts) > 3 { + return "", "", "", false, nil + } + records, err := lookupTXT("_bgit." + parsed.Hostname()) + if err != nil { + return "", "", "", false, nil + } + for _, record := range records { + if broker, teamID := brokerDiscoveryFromTXTRecord(record, teamName); broker != "" { + return broker, logical, teamID, true, nil + } + } + return "", "", "", false, nil +} + +func brokerURLFromTXTRecord(record string) string { + broker, _ := brokerDiscoveryFromTXTRecord(record, "") + return broker +} + +func brokerDiscoveryFromTXTRecord(record, teamName string) (string, string) { + fields := strings.Fields(strings.TrimSpace(record)) + if len(fields) == 0 || fields[0] != "v=bgit1" { + return "", "" + } + broker := "" + teamID := "" + name := "" + for _, field := range fields[1:] { + if strings.HasPrefix(field, "broker=") { + broker = strings.TrimRight(strings.TrimPrefix(field, "broker="), "/") + } + if strings.HasPrefix(field, "team=") { + teamID = strings.TrimSpace(strings.TrimPrefix(field, "team=")) + } + if strings.HasPrefix(field, "name=") { + name = strings.TrimSpace(strings.TrimPrefix(field, "name=")) + } + } + if broker == "" { + return "", "" + } + if teamName == "" { + return broker, teamID + } + if name == teamName || teamID == teamName { + return broker, firstNonEmpty(teamID, teamName) + } + return "", "" +} + +func resolveBrokerTeamName(brokerURL, teamName string) (string, error) { + teamName = strings.TrimSpace(teamName) + if teamName == "" || teamName == coreTeamName || teamName == coreTeamID { + return coreTeamID, nil + } + var resp struct { + Team brokerTeamInfo `json:"team"` + } + if err := brokerPost(brokerURL, "/teams/resolve", brokerRepoAdminRequest{Name: teamName}, &resp); err != nil { + return "", err + } + if strings.TrimSpace(resp.Team.ID) == "" { + return "", fmt.Errorf("team %q not found", teamName) + } + return resp.Team.ID, nil +} + +func isDirectBrokerHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + return strings.HasSuffix(host, ".lambda-url.us-east-1.on.aws") || + strings.Contains(host, ".lambda-url.") && strings.HasSuffix(host, ".on.aws") || + strings.HasSuffix(host, ".run.app") || + strings.HasSuffix(host, ".cloudfunctions.net") } func brokerProfileForCloneURL(brokerURL string) (brokerProfile, error) { @@ -1551,6 +2329,64 @@ func brokerInitPrompt(stdin io.Reader, stdout io.Writer, initial initDialogConfi return runInitDialogWithRaw(reader, stdin, stdout, initial, profiles) } +func brokerInitSelectTeam(stdin io.Reader, stdout io.Writer, profile brokerProfile) (string, error) { + teams, err := brokerInitTeamChoices(profile) + if err != nil { + return "", err + } + if len(teams) == 0 { + return "", errors.New("no teams available for selected broker") + } + reader, ok := stdin.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(stdin) + } + value, ok, err := runSetupSelectWithRaw(reader, stdin, stdout, "Select team", teams, "") + if err != nil { + return "", err + } + if !ok || strings.TrimSpace(value) == "" { + return "", errors.New("init canceled") + } + return value, nil +} + +func brokerInitTeamChoices(profile brokerProfile) ([]setupChoice, error) { + var resp brokerTeamsResponse + if err := brokerPost(profile.BrokerURL, "/teams/list", brokerRepoAdminRequest{}, &resp); err != nil { + repos, repoErr := brokerReposMineAllKeys(context.Background(), profile.BrokerURL) + if repoErr != nil { + return nil, err + } + seen := map[string]bool{} + var choices []setupChoice + for _, repo := range repos { + teamID := strings.TrimSpace(repo.Repo.TeamID) + if teamID == "" { + teamID = coreTeamID + } + if seen[teamID] { + continue + } + seen[teamID] = true + choices = append(choices, setupChoice{Label: teamID, Value: teamID}) + } + sort.Slice(choices, func(i, j int) bool { return choices[i].Label < choices[j].Label }) + return choices, nil + } + choices := make([]setupChoice, 0, len(resp.Teams)) + for _, team := range resp.Teams { + label := firstNonEmpty(strings.TrimSpace(team.Name), strings.TrimSpace(team.ID)) + value := strings.TrimSpace(team.ID) + if value == "" { + continue + } + choices = append(choices, setupChoice{Label: label, Value: value}) + } + sort.Slice(choices, func(i, j int) bool { return choices[i].Label < choices[j].Label }) + return choices, nil +} + type initDialogState struct { repoName string initialRepoName string @@ -2038,6 +2874,16 @@ func initBrokerWorktree(target, repoName string, profile brokerProfile, identity if err != nil { return err } + repoName, err = normalizeLogicalRepoName(repoName) + if err != nil { + return err + } + if strings.TrimSpace(profile.TeamID) == "" { + return errors.New("init requires a selected team") + } + if err := brokerRequireExistingLogicalRepo(profile.BrokerURL, profile.Provider, repoName, profile.TeamID); err != nil { + return err + } if err := os.MkdirAll(absTarget, 0o755); err != nil { return err } @@ -2046,10 +2892,6 @@ func initBrokerWorktree(target, repoName string, profile brokerProfile, identity return err } } - repoName, err = normalizeLogicalRepoName(repoName) - if err != nil { - return err - } remoteURL := fmt.Sprintf("git@%s:%s", defaultSSHHost, repoName) sshCommand := gitSSHCommandForExecutable() pairs := [][]string{ @@ -2060,6 +2902,9 @@ func initBrokerWorktree(target, repoName string, profile brokerProfile, identity {"bucketgit.logicalRepo", repoName}, {"core.sshCommand", sshCommand}, } + if strings.TrimSpace(profile.TeamID) != "" { + pairs = append(pairs, []string{"bucketgit.team", strings.TrimSpace(profile.TeamID)}) + } if strings.TrimSpace(identityName) != "" { pairs = append(pairs, []string{"user.name", strings.TrimSpace(identityName)}) } @@ -2080,14 +2925,29 @@ func initBrokerWorktree(target, repoName string, profile brokerProfile, identity if err := setGitBranchTracking(absTarget, defaultBranch, "origin"); err != nil { return err } - if err := brokerUpsertLogicalRepo(profile.BrokerURL, profile.Provider, repoName); err != nil { - fmt.Fprintf(stdout, "broker repo registration skipped: %v\n", err) - } fmt.Fprintf(stdout, "Initialized broker-backed BucketGit repository in %s/\n", filepath.Join(absTarget, ".git")) fmt.Fprintf(stdout, "configured origin %s\n", remoteURL) return nil } +func brokerRequireExistingLogicalRepo(brokerURL, provider, logicalRepo, teamID string) error { + logical, err := normalizeLogicalRepoName(logicalRepo) + if err != nil { + return err + } + if strings.TrimSpace(teamID) == "" { + return errors.New("team is required") + } + cfg := config{ + provider: provider, + prefix: logical, + logicalRepo: logical, + origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logical), + teamID: strings.TrimSpace(teamID), + } + return brokerPost(brokerURL, "/repos/get", brokerRepoRequest{Repo: repoForBroker(cfg)}, nil) +} + func gitSSHCommandForExecutable() string { exe, err := os.Executable() if err != nil || strings.TrimSpace(exe) == "" { diff --git a/broker_commands_test.go b/broker_commands_test.go index f9ee920..1e45409 100644 --- a/broker_commands_test.go +++ b/broker_commands_test.go @@ -15,6 +15,24 @@ import ( func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { root := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/get" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + var req brokerRepoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.Repo.Logical != "app.git" { + t.Fatalf("logical repo = %q", req.Repo.Logical) + } + if req.Repo.TeamID != coreTeamID { + t.Fatalf("team = %q", req.Repo.TeamID) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() configPath := filepath.Join(root, ".bgit", "config") if err := writeGlobalConfig(configPath, globalConfig{ Version: globalConfigVersion, @@ -23,7 +41,7 @@ func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { ProjectID: "project-id", Regions: []globalProfileRegion{{ Name: "europe-west1", - BrokerURL: "https://broker.example.test", + BrokerURL: server.URL, }}, }}, }); err != nil { @@ -31,14 +49,15 @@ func TestBrokerInitWritesBrokerGitConfig(t *testing.T) { } target := filepath.Join(root, "app") var stdout bytes.Buffer - err := brokerInitCommand([]string{"--noninteractive", "--repo", "app", target, "--profile", "gcp:work/europe-west1", "--config", configPath}, strings.NewReader(""), &stdout) + err := brokerInitCommand([]string{"--noninteractive", "--repo", "app", target, "--profile", "gcp:work/europe-west1", "--team", "core", "--config", configPath}, strings.NewReader(""), &stdout) if err != nil { t.Fatal(err) } for key, want := range map[string]string{ - "bucketgit.broker": "https://broker.example.test", + "bucketgit.broker": server.URL, "bucketgit.profile": "gcp:work/europe-west1", "bucketgit.region": "europe-west1", + "bucketgit.team": coreTeamID, "bucketgit.logicalRepo": "app.git", "branch.main.remote": "origin", "branch.main.merge": "refs/heads/main", @@ -89,10 +108,19 @@ func TestShellQuoteForGitSSHCommand(t *testing.T) { func TestInitBrokerWorktreeOmitsIdentityWhenUnset(t *testing.T) { target := filepath.Join(t.TempDir(), "app") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/get" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() err := initBrokerWorktree(target, "app", brokerProfile{ Provider: "gcs", QualifiedName: "broker:https://broker.example.test", - BrokerURL: "", + BrokerURL: server.URL, + TeamID: coreTeamID, }, "", "", io.Discard) if err != nil { t.Fatal(err) @@ -114,6 +142,10 @@ func TestBrokerInitNoninteractiveRequiresProfileAndRepo(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "requires --repo") { t.Fatalf("err = %v", err) } + err = brokerInitCommand([]string{"--noninteractive", "--profile", "work", "--repo", "app"}, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "requires --team") { + t.Fatalf("err = %v", err) + } } func TestAdminKeysListUsesLogicalBrokerRepo(t *testing.T) { @@ -150,8 +182,64 @@ func TestAdminKeysListUsesLogicalBrokerRepo(t *testing.T) { } } +func TestInviteUserPreservesTeamScopedRepo(t *testing.T) { + var got brokerOwnerTransferRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/keys/invite/create" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"accept_command":"bgit admin accept-invite bgitinv_test"}`)) + })) + defer server.Close() + + var stdout bytes.Buffer + if err := brokerInviteUserCommand(config{provider: "s3"}, []string{"--broker", server.URL, "--team", "t_marketing", "--user", "owner", "--role", "read", "mkt"}, &stdout); err != nil { + t.Fatal(err) + } + if got.Repo.Logical != "mkt.git" || got.Repo.TeamID != "t_marketing" || got.Repo.Provider != "s3" { + t.Fatalf("repo = %#v", got.Repo) + } + if got.User != "owner" || got.Role != "read" { + t.Fatalf("request = %#v", got) + } +} + +func TestPrintBrokerUsersUsesReadableColumns(t *testing.T) { + var stdout bytes.Buffer + printBrokerUsers(&stdout, []brokerUserInfo{{ + ID: "u_owner", + Username: "owner", + BrokerRole: "owner", + Keys: []brokerKey{{PublicKey: "ssh-ed25519 AAAA owner"}}, + }, { + ID: "u_pending", + Username: "pending", + BrokerRole: "user", + Pending: true, + }}) + out := stdout.String() + for _, want := range []string{"ID", "Username", "Role", "Status", "u_owner", "owner", "active", "u_pending", "pending"} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in %q", want, out) + } + } + if strings.Contains(out, "\t") { + t.Fatalf("output should not contain tabs: %q", out) + } +} + func TestTopLevelBrokerInitForwardsGlobalProfile(t *testing.T) { root := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/get" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() configPath := filepath.Join(root, ".bgit", "config") if err := writeGlobalConfig(configPath, globalConfig{ Version: globalConfigVersion, @@ -160,7 +248,7 @@ func TestTopLevelBrokerInitForwardsGlobalProfile(t *testing.T) { ProjectID: "project-id", Regions: []globalProfileRegion{{ Name: "europe-west1", - BrokerURL: "https://broker.example.test", + BrokerURL: server.URL, }}, }}, AWSProfiles: []globalAWSProfile{{ @@ -176,7 +264,7 @@ func TestTopLevelBrokerInitForwardsGlobalProfile(t *testing.T) { } target := filepath.Join(root, "app") var stdout bytes.Buffer - err := run([]string{"init", "--noninteractive", "--repo", "app", target, "--config", configPath, "--profile", "gcp:work/europe-west1"}, strings.NewReader(""), &stdout, ioDiscard{}) + err := run([]string{"init", "--noninteractive", "--repo", "app", target, "--config", configPath, "--profile", "gcp:work/europe-west1", "--team", "core"}, strings.NewReader(""), &stdout, ioDiscard{}) if err != nil { t.Fatal(err) } @@ -319,12 +407,19 @@ func TestBrokerProfileDotRegionSelectsProfile(t *testing.T) { } func TestParseBrokerCloneURL(t *testing.T) { - brokerURL, repo, ok, err := parseBrokerCloneURL("https://broker.example.test/app.git") + brokerURL, repo, teamName, ok, err := parseBrokerCloneURL("https://broker.example.test/app.git") if err != nil { t.Fatal(err) } - if !ok || brokerURL != "https://broker.example.test" || repo != "app.git" { - t.Fatalf("brokerURL=%q repo=%q ok=%v", brokerURL, repo, ok) + if !ok || brokerURL != "https://broker.example.test" || repo != "app.git" || teamName != "" { + t.Fatalf("brokerURL=%q repo=%q team=%q ok=%v", brokerURL, repo, teamName, ok) + } + brokerURL, repo, teamName, ok, err = parseBrokerCloneURL("https://broker.example.test/core/app/app.git") + if err != nil { + t.Fatal(err) + } + if !ok || brokerURL != "https://broker.example.test" || repo != "app.git" || teamName != "core" { + t.Fatalf("brokerURL=%q repo=%q team=%q ok=%v", brokerURL, repo, teamName, ok) } } @@ -334,8 +429,52 @@ func TestLogicalRepoNamesMustBeFlat(t *testing.T) { t.Fatalf("normalizeLogicalRepoName(%q) succeeded", name) } } - if _, _, _, err := parseBrokerCloneURL("https://broker.example.test/team/app.git"); err == nil { - t.Fatal("parseBrokerCloneURL accepted path-shaped logical repo") + if _, _, _, _, err := parseBrokerCloneURL("https://broker.example.test/team/other/app.git"); err == nil { + t.Fatal("parseBrokerCloneURL accepted mismatched team repo path") + } +} + +func TestDiscoverBrokerCloneURLUsesTXTTeamName(t *testing.T) { + oldLookup := lookupTXT + lookupTXT = func(name string) ([]string, error) { + if name != "_bgit.git.example.com" { + t.Fatalf("lookup name = %q", name) + } + return []string{`v=bgit1 broker=https://broker.example.test team=t_abc123 name=teamfoobar`}, nil + } + defer func() { lookupTXT = oldLookup }() + + brokerURL, repo, teamID, ok, err := discoverBrokerCloneURL("https://git.example.com/teamfoobar/repo/repo.git") + if err != nil { + t.Fatal(err) + } + if !ok || brokerURL != "https://broker.example.test" || repo != "repo.git" || teamID != "t_abc123" { + t.Fatalf("brokerURL=%q repo=%q teamID=%q ok=%v", brokerURL, repo, teamID, ok) + } + + _, _, _, ok, err = discoverBrokerCloneURL("https://git.example.com/teamfoobar/other/repo.git") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("discovered broker for mismatched GitHub-style repo path") + } +} + +func TestDiscoverBrokerCloneURLSkipsDirectBrokerHosts(t *testing.T) { + oldLookup := lookupTXT + lookupTXT = func(name string) ([]string, error) { + t.Fatalf("unexpected TXT lookup %q", name) + return nil, nil + } + defer func() { lookupTXT = oldLookup }() + + _, _, _, ok, err := discoverBrokerCloneURL("https://service.run.app/teamfoobar/repo.git") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("discovered broker for direct Cloud Run host") } } @@ -353,6 +492,13 @@ func TestBrokerProfileForCloneURL(t *testing.T) { func TestBrokerInitInteractivePromptsForRepoAndProfile(t *testing.T) { root := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/get" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() configPath := filepath.Join(root, ".bgit", "config") if err := writeGlobalConfig(configPath, globalConfig{ Version: globalConfigVersion, @@ -361,7 +507,7 @@ func TestBrokerInitInteractivePromptsForRepoAndProfile(t *testing.T) { AccountID: "123456789012", Regions: []globalProfileRegion{{ Name: "eu-west-1", - BrokerURL: "https://broker.example.test", + BrokerURL: server.URL, }}, }}, }); err != nil { @@ -369,7 +515,7 @@ func TestBrokerInitInteractivePromptsForRepoAndProfile(t *testing.T) { } target := filepath.Join(root, "repo") var stdout bytes.Buffer - err := brokerInitCommand([]string{"--config", configPath, "--profile", "aws:prod/eu-west-1", "ignored", target}, strings.NewReader("\x04"), &stdout) + err := brokerInitCommand([]string{"--config", configPath, "--profile", "aws:prod/eu-west-1", "--team", "core", "ignored", target}, strings.NewReader("\x04"), &stdout) if err != nil { t.Fatal(err) } @@ -625,6 +771,83 @@ func TestAdminCloudIAMMovedToDirect(t *testing.T) { } } +func TestAdminRepoCreateUsesCreateEndpointAndTeam(t *testing.T) { + var got brokerRepoAdminRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/teams/resolve": + _, _ = w.Write([]byte(`{"team":{"id":"t_marketing","name":"marketing"}}`)) + case "/repos/create": + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + var stdout bytes.Buffer + err := brokerAdminCommand(config{provider: "gcs", brokerURL: server.URL}, []string{"repo", "create", "--team", "marketing", "--role", "developer", "demo"}, &stdout) + if err != nil { + t.Fatal(err) + } + if got.Repo.Logical != "demo.git" || got.Repo.TeamID != "t_marketing" || got.Role != "developer" { + t.Fatalf("request = %#v", got) + } + if !strings.Contains(stdout.String(), "created repository demo in team marketing") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestAdminRepoCreateAllowsOwnerTeamGrant(t *testing.T) { + var got brokerRepoAdminRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/teams/resolve": + _, _ = w.Write([]byte(`{"team":{"id":"t_core","name":"core"}}`)) + case "/repos/create": + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + if err := brokerAdminCommand(config{provider: "gcs", brokerURL: server.URL}, []string{"repo", "create", "--team", "core", "--role", "owner", "demo"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if got.Role != "owner" || got.Repo.TeamID != "t_core" { + t.Fatalf("request = %#v", got) + } +} + +func TestAdminTeamsRepoAddAllowsOwnerRole(t *testing.T) { + var got brokerRepoAdminRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + if r.URL.Path != "/repo/teams/upsert" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + cfg := config{brokerURL: server.URL, logicalRepo: "demo.git", provider: "gcs"} + if err := brokerTeamsCommand(cfg, []string{"repo", "add", "t_core", "owner"}, ioDiscard{}); err != nil { + t.Fatal(err) + } + if got.TeamID != "t_core" || got.Role != "owner" || got.Repo.Logical != "demo.git" { + t.Fatalf("request = %#v", got) + } +} + func TestBrokerAdminProtectAndPRCommandsUseBroker(t *testing.T) { target, server, requests := setupBrokerCommandTestRepo(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") diff --git a/main.go b/main.go index f446a9e..1a2a19e 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ import ( const defaultBranch = "main" const defaultAuthMode = "gcloud" -const brokerVersion = "1.0.1-dev" +const brokerVersion = "1.1.0" var version = "dev" @@ -33,6 +33,7 @@ type config struct { origin string brokerURL string logicalRepo string + teamID string region string auth string gcloudConfiguration string @@ -519,6 +520,9 @@ func mergeConfig(primary, fallback config) config { if primary.logicalRepo == "" { primary.logicalRepo = fallback.logicalRepo } + if primary.teamID == "" { + primary.teamID = fallback.teamID + } if primary.region == "" { primary.region = fallback.region } @@ -598,6 +602,10 @@ func readLocalConfig(dir string) (config, error) { return config{}, err } } + teamID := "" + if teamOut, teamErr := runGit(dir, "config", "--get", "bucketgit.team"); teamErr == nil { + teamID = strings.TrimSpace(string(teamOut)) + } localRegion := "" if regionOut, regionErr := runGit(dir, "config", "--get", "bucketgit.region"); regionErr == nil { localRegion = strings.TrimSpace(string(regionOut)) @@ -616,6 +624,7 @@ func readLocalConfig(dir string) (config, error) { origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logicalRepo), brokerURL: brokerURL, logicalRepo: logicalRepo, + teamID: teamID, region: localRegion, identity: identity, auth: localAuth.auth, @@ -963,30 +972,35 @@ func helpPages() map[string]string { "clone": `usage: bgit clone [directory] bgit clone https://broker.example.com/app.git [directory] + bgit clone https://broker.example.com/team/app.git [directory] + bgit clone https://broker.example.com/team/app/app.git [directory] bgit clone --broker https://broker.example.com app.git [directory] -Clone a BucketGit repository by logical repo name. Passing a broker URL makes -the checkout self-contained and does not require a local profile. Direct -object-storage clone moved to bgit direct clone. +Clone a BucketGit repository by logical repo name. Flat broker URLs use the +default core team. Team URLs can use /team/repo.git or /team/repo/repo.git. +Passing a broker URL makes the checkout self-contained and does not require a +local profile. Direct object-storage clone moved to bgit direct clone. examples: bgit clone app.git bgit clone https://bgit-broker.example.com/app.git + bgit clone https://git.example.com/platform/app.git + bgit clone https://git.example.com/platform/app/app.git bgit direct clone gs://my-bucket/repositories/app.git bgit direct clone s3://my-bucket/repositories/app.git --profile aws-profile `, "init": `usage: bgit init - bgit init --noninteractive --repo NAME --profile PROFILE[.REGION] [--region REGION] [directory] + bgit init --noninteractive --repo NAME --profile PROFILE[.REGION] --team TEAM [--region REGION] [directory] -Create a local Git repository and attach it to a BucketGit repository from +Create a local Git repository and attach it to an existing BucketGit repository from ~/.bgit/config.yaml. Without --noninteractive, init prompts for missing repo, -profile, and region choices. +profile, region, and team choices. examples: bgit init - bgit init --noninteractive --repo app --profile gcp:work.europe-west1 - bgit init --noninteractive --repo app --profile work --region europe-west1 + bgit init --noninteractive --repo app --profile gcp:work.europe-west1 --team core + bgit init --noninteractive --repo app --profile work --region europe-west1 --team core `, "setup": `usage: bgit setup @@ -1036,9 +1050,15 @@ Configure a direct bucketgit origin using Git remote syntax. `, "admin": `usage: bgit admin keys list|add|remove|suspend|import-github [args] - bgit admin invite-user --broker URL --user USER [--role ROLE] REPO + bgit admin broker-users list|upsert USER [--role admin|user] [--key PATH_OR_PUBLIC_KEY]|delete USER + bgit admin teams list|create NAME|delete TEAM|member add TEAM USER [--role ROLE]|member remove TEAM USER + bgit admin teams repo list|repo add TEAM ROLE|repo remove TEAM + bgit admin repo list + bgit admin repo info + bgit admin repo create --team TEAM [--role ROLE] REPO + bgit admin invite-user --broker URL [--team TEAM] --user USER [--role ROLE] REPO bgit admin accept-invite CODE - bgit admin cancel-invite --broker URL --user USER REPO + bgit admin cancel-invite --broker URL [--team TEAM] --user USER REPO bgit admin confirm-ownership-transfer --broker URL REPO bgit admin accept-ownership-transfer CODE bgit admin cancel-ownership-transfer [--broker URL REPO] @@ -1056,6 +1076,15 @@ examples: bgit admin keys list bgit admin keys add --user ada --role developer --key ~/.ssh/ada.pub bgit admin keys import-github octocat --role read + bgit admin broker-users upsert ada --role user --key ~/.ssh/ada.pub + bgit admin teams create platform + bgit admin teams delete TEAM_ID + bgit admin teams member add TEAM_ID ada --role developer + bgit admin teams repo list + bgit admin teams repo add TEAM_ID developer + bgit admin repo list + bgit admin repo info + bgit admin repo create --team platform app bgit admin invite-user --broker https://broker.example.com --user ada --role developer app bgit admin protect add main bgit admin repo visibility public diff --git a/main_test.go b/main_test.go index e652271..2ec17c3 100644 --- a/main_test.go +++ b/main_test.go @@ -3293,6 +3293,26 @@ func TestWebRepoHeaderUsesShortTitleAndBrokerLocationBadge(t *testing.T) { if !strings.Contains(header, `data-theme-toggle`) { t.Fatalf("header missing theme toggle: %s", header) } + if strings.Contains(header, `href="/admin"`) { + t.Fatalf("header should not include separate repo admin tab: %s", header) + } + if strings.Contains(header, `href="/broker-admin"`) || strings.Contains(header, "Broker Admin") { + t.Fatalf("header should not include broker admin tab: %s", header) + } +} + +func TestWebBrokerAdminRouteIsGone(t *testing.T) { + bare := filepath.Join(t.TempDir(), "repo.git") + if _, err := runGit("", "init", "--bare", bare); err != nil { + t.Fatal(err) + } + handler := newWebHandlerWithAPI(newNativeGitRepoForStore(config{branch: "main"}, &localGitStore{root: bare}), nil, config{branch: "main"}) + req := httptest.NewRequest(http.MethodGet, "/broker-admin", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } } func TestWebHandlerCanRenderSeedThenRemote(t *testing.T) { diff --git a/setup.go b/setup.go index 65a72c0..f96f678 100644 --- a/setup.go +++ b/setup.go @@ -39,6 +39,7 @@ type setupOptions struct { region string keys []string noAgent bool + action string } type setupProfile struct { @@ -81,6 +82,14 @@ type brokerOwnerRequest struct { PublicKeys []string `json:"public_keys,omitempty"` } +type setupConfiguredBroker struct { + Provider string + Profile string + Region string + BrokerURL string + Detail string +} + func setupCommand(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) >= 2 && args[0] == "profile" && args[1] == "create" { return setupProfileCreateCommand(args[2:], stdin, stdout) @@ -117,6 +126,74 @@ func setupCommand(ctx context.Context, base config, args []string, stdin io.Read } profiles = markConfiguredSetupProfiles(profiles, global) profiles = filterSetupProfiles(profiles, opts.provider, opts.profiles, opts.region) + interactiveBrokerMenu := !opts.yes && len(opts.profiles) == 0 && opts.provider == "" && opts.region == "" + if interactiveBrokerMenu { + brokerMenu: + for { + action, broker, err := runSetupBrokerHomeWithRaw(interactiveReader, stdin, stdout, configuredSetupBrokers(global), setupAvailableCreateProviders()) + if err != nil { + return err + } + if action == "broker" { + for { + action, err = runSetupBrokerActionWithRaw(interactiveReader, stdin, stdout, broker) + if err != nil { + return err + } + if action == "back" { + continue brokerMenu + } + if action != "manage" { + break + } + err = runSetupBrokerManageWithRaw(base, interactiveReader, stdin, stdout, broker) + if errors.Is(err, errSetupBack) { + continue + } + if err != nil { + return err + } + return setupReturnToMenuOrQuit(ctx, base, args, stdin, stdout, interactiveReader) + } + } + switch action { + case "cancel": + return nil + case "back": + continue brokerMenu + case "manage": + if err := runSetupBrokerManageWithRaw(base, interactiveReader, stdin, stdout, broker); err != nil { + return err + } + return setupReturnToMenuOrQuit(ctx, base, args, stdin, stdout, interactiveReader) + case "delete": + ok, err := runSetupBrokerDeleteConfirmWithRaw(interactiveReader, stdin, stdout, broker) + if err != nil { + return err + } + if !ok { + continue brokerMenu + } + if err := brokerDeleteCommand(ctx, base, []string{"--provider", setupProviderLabel(broker.Provider), "--profile", broker.Profile, "--region", broker.Region, "--yes"}, stdin, stdout); err != nil { + return err + } + return setupReturnToMenuOrQuit(ctx, base, args, stdin, stdout, interactiveReader) + case "update": + opts.action = "update" + opts.provider = broker.Provider + opts.profiles = []string{broker.Profile} + opts.region = broker.Region + profiles = filterSetupProfiles(markConfiguredSetupProfiles(profilesWithoutConfiguredExpansion(profiles), global), opts.provider, opts.profiles, opts.region) + break brokerMenu + case "new": + opts.action = "new" + break brokerMenu + default: + opts.action = "upsert" + break brokerMenu + } + } + } if len(profiles) == 0 { if opts.yes { return errors.New("no cloud profiles found; install/configure gcloud or AWS CLI profiles first") @@ -139,7 +216,10 @@ selectAgain: DefaultCreate: firstSetupRequestedProfile(opts), DefaultCreateByProvider: setupCreateProfileDefaults(profiles, opts), } - if !opts.yes { + if opts.action == "update" { + selection.Profiles = profiles + selection.Keys = nil + } else if !opts.yes { selected, err := runSetupDialogWithRaw(interactiveReader, stdin, stdout, selection) if err != nil { return err @@ -177,6 +257,9 @@ selectAgain: return err } fmt.Fprintf(stdout, "wrote BucketGit config %s\n", path) + if interactiveBrokerMenu { + return setupReturnToMenuOrQuit(ctx, base, args, stdin, stdout, interactiveReader) + } return nil } var publicKeys []string @@ -255,13 +338,34 @@ selectAgain: return err } fmt.Fprintf(stdout, "wrote BucketGit config %s\n", path) - fmt.Fprintln(stdout) - fmt.Fprintln(stdout, "Next steps:") - fmt.Fprintln(stdout, " bgit init") - fmt.Fprintln(stdout, " bgit init --noninteractive --repo my-repo --profile PROFILE") - fmt.Fprintln(stdout, " git push -u origin main") + if opts.action != "update" { + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "Next steps:") + fmt.Fprintln(stdout, " bgit init") + fmt.Fprintln(stdout, " bgit init --noninteractive --repo my-repo --profile PROFILE") + fmt.Fprintln(stdout, " git push -u origin main") + } + if interactiveBrokerMenu { + return setupReturnToMenuOrQuit(ctx, base, args, stdin, stdout, interactiveReader) + } + return nil + } +} + +func setupReturnToMenuOrQuit(ctx context.Context, base config, args []string, stdin io.Reader, stdout io.Writer, reader *bufio.Reader) error { + fmt.Fprintln(stdout) + fmt.Fprint(stdout, "Press to return to the menu or press to quit. ") + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + if errors.Is(err, io.EOF) && strings.TrimSpace(line) == "" { + return nil + } + if strings.EqualFold(strings.TrimSpace(line), "q") { return nil } + return setupCommand(ctx, base, args, stdin, stdout) } func setupProfileCreateCommand(args []string, stdin io.Reader, stdout io.Writer) error { @@ -364,8 +468,111 @@ func setupCreateProfileDefaults(profiles []setupProfile, opts setupOptions) map[ return defaults } +func configuredSetupBrokers(cfg globalConfig) []setupConfiguredBroker { + var brokers []setupConfiguredBroker + for _, profile := range cfg.GCPProfiles { + for _, region := range profile.Regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + brokers = append(brokers, setupConfiguredBroker{ + Provider: "gcs", + Profile: profile.Name, + Region: region.Name, + BrokerURL: region.BrokerURL, + Detail: firstNonEmpty(profile.ProjectID, profile.Account, profile.ServiceAccount), + }) + } + } + for _, profile := range cfg.AWSProfiles { + for _, region := range profile.Regions { + if strings.TrimSpace(region.BrokerURL) == "" { + continue + } + brokers = append(brokers, setupConfiguredBroker{ + Provider: "s3", + Profile: profile.Name, + Region: region.Name, + BrokerURL: region.BrokerURL, + Detail: firstNonEmpty(profile.AccountID, profile.ARN), + }) + } + } + sort.Slice(brokers, func(i, j int) bool { + a := setupBrokerQualifiedName(brokers[i]) + b := setupBrokerQualifiedName(brokers[j]) + if a == b { + return brokers[i].BrokerURL < brokers[j].BrokerURL + } + return a < b + }) + return brokers +} + +func configuredSetupBrokerExists(cfg globalConfig, provider, profile, region string) bool { + provider = normalizeSetupProvider(provider) + profile = strings.TrimSpace(profile) + region = strings.TrimSpace(region) + for _, broker := range configuredSetupBrokers(cfg) { + if broker.Provider == provider && broker.Profile == profile && broker.Region == region { + return true + } + } + return false +} + +func profilesWithoutConfiguredExpansion(profiles []setupProfile) []setupProfile { + seen := map[string]struct{}{} + var out []setupProfile + for _, profile := range profiles { + key := profile.Provider + "\x00" + profile.Name + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + profile.Existing = false + profile.ConfiguredRegions = nil + out = append(out, profile) + } + return out +} + +func setupBrokerQualifiedName(broker setupConfiguredBroker) string { + name := setupProviderLabel(broker.Provider) + ":" + broker.Profile + if strings.TrimSpace(broker.Region) != "" { + name += "." + broker.Region + } + return name +} + +func printSetupBrokerManagement(stdout io.Writer, broker setupConfiguredBroker) { + fmt.Fprintf(stdout, "Manage %s\n", setupBrokerQualifiedName(broker)) + fmt.Fprintf(stdout, "Broker: %s\n\n", broker.BrokerURL) + profileArgs := fmt.Sprintf("--profile %s --region %s", broker.Profile, broker.Region) + fmt.Fprintln(stdout, "Common broker management commands:") + fmt.Fprintf(stdout, " bgit %s admin broker-users list\n", profileArgs) + fmt.Fprintf(stdout, " bgit %s admin invite-broker-user USER --role user\n", profileArgs) + fmt.Fprintf(stdout, " bgit %s admin teams list\n", profileArgs) + fmt.Fprintf(stdout, " bgit %s admin teams create TEAM\n", profileArgs) + fmt.Fprintf(stdout, " bgit admin invite-user --broker %s --team TEAM --user USER --role developer REPO\n", broker.BrokerURL) + fmt.Fprintf(stdout, " bgit admin confirm-ownership-transfer --broker %s REPO\n", broker.BrokerURL) +} + +func setupBrokerConfig(base config, broker setupConfiguredBroker) config { + cfg := base + cfg.provider = broker.Provider + cfg.gcloudConfiguration = broker.Profile + cfg.gcloudConfigurationExplicit = broker.Profile != "" + cfg.region = broker.Region + cfg.brokerURL = broker.BrokerURL + return cfg +} + func setupProvisionSelectedProfile(base config, path, now string, profile setupProfile, opts setupOptions, publicKeys []string, global *globalConfig, stdout io.Writer) error { _ = path + if opts.action == "new" && configuredSetupBrokerExists(*global, profile.Provider, profile.Name, profile.Region) { + return fmt.Errorf("%s:%s.%s already has a broker; choose Update broker to redeploy it", setupProviderLabel(profile.Provider), profile.Name, profile.Region) + } cfg := base cfg.provider = profile.Provider cfg.gcloudConfiguration = profile.Name @@ -379,6 +586,8 @@ func setupProvisionSelectedProfile(base config, path, now string, profile setupP return err } fmt.Fprintf(stdout, "imported %d owner key(s) into broker %s\n", len(publicKeys), brokerURL) + } else if err := brokerEnsureCoreTeam(brokerURL); err != nil { + return err } switch profile.Provider { case "gcs": @@ -1826,24 +2035,25 @@ func runSetupRegionDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdou case 'q', 'Q': return nil, errSetupBack case 0x1b: - next, err := reader.ReadByte() + last, ok, err := setupReadEscapeSequence(reader) if err != nil { + return nil, err + } + if !ok { return nil, errSetupBack } - if next == '[' { - last, err := reader.ReadByte() - if err != nil { - return nil, errSetupBack - } - switch last { - case 'A': - state.up() - case 'B': - state.down() + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + if regions, ok := state.activate(); ok { + return regions, nil } - continue + case 'D': + return nil, errSetupBack } - return nil, errSetupBack } } } @@ -2132,32 +2342,39 @@ func runSetupDialogWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.W state.message = "" continue } - next, err := reader.ReadByte() + last, ok, err := setupReadEscapeSequence(reader) if err != nil { + return setupSelection{}, err + } + if !ok { if state.createProvider != "" { state.cancelCreateProfile() continue } return setupSelection{}, errors.New("setup canceled") } - if next == '[' { - last, err := reader.ReadByte() - if err != nil { + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + if selected, ok := state.activate(); ok { + return selected, nil + } else if state.button == 1 { return setupSelection{}, errors.New("setup canceled") } - switch last { - case 'A': - state.up() - case 'B': - state.down() + case 'D': + if state.createProvider != "" { + state.cancelCreateProfile() + continue + } + return setupSelection{}, errors.New("setup canceled") + default: + if state.createProvider != "" { + state.cancelCreateProfile() } - continue - } - if state.createProvider != "" { - state.cancelCreateProfile() - continue } - return setupSelection{}, errors.New("setup canceled") default: if state.editingCreate && b >= 32 && b <= 126 { state.appendCreateByte(b) @@ -2233,107 +2450,3097 @@ type setupDialogVisibleItem struct { Label string } -func setupAvailableCreateProviders() []string { - var providers []string - if _, err := exec.LookPath("gcloud"); err == nil { - providers = append(providers, "gcs") - } - if _, err := exec.LookPath("aws"); err == nil { - providers = append(providers, "s3") - } - return providers +type setupBrokerHomeState struct { + Brokers []setupConfiguredBroker + CreateProviders []string + Cursor int + Scroll int + Button int + Message string } -func (s setupDialogState) selection() setupSelection { - var profiles []setupProfile - for i, profile := range s.profiles { - if i < len(s.selectedProfiles) && s.selectedProfiles[i] { - profiles = append(profiles, profile) - } +type setupBrokerActionState struct { + Broker setupConfiguredBroker + Cursor int + Button int + Message string +} + +type setupBrokerManageState struct { + Broker setupConfiguredBroker + Cursor int + Scroll int + Button int + Message string +} + +type setupBrokerManageAction struct { + ID string + Label string + Help string +} + +type setupTextField struct { + Label string + Value string + Secret bool + Required bool +} + +type setupTextFormState struct { + Title string + Fields []setupTextField + Cursor int + Button int + Editing bool + EditOriginal string + Message string +} + +type setupChoice struct { + Label string + Value string + Help string + Group string +} + +type setupSelectState struct { + Title string + Choices []setupChoice + Cursor int + Scroll int + Button int + Message string +} + +type setupMultiSelectState struct { + Title string + Choices []setupChoice + Selected []bool + Cursor int + Scroll int + Button int + Message string +} + +func runSetupBrokerHomeWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, brokers []setupConfiguredBroker, createProviders []string) (string, setupConfiguredBroker, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return "", setupConfiguredBroker{}, err } - var keys []setupSSHKey - for i, key := range s.keys { - if i < len(s.selectedKeys) && s.selectedKeys[i] { - keys = append(keys, key) + defer restore() + state := setupBrokerHomeState{Brokers: brokers, CreateProviders: createProviders, Button: -1} + for { + fmt.Fprint(stdout, renderSetupBrokerHomeFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return "cancel", setupConfiguredBroker{}, nil + } + return "", setupConfiguredBroker{}, err + } + switch b { + case 0x03: + return "", setupConfiguredBroker{}, errors.New("setup canceled") + case 0x04: + action, broker, ok := state.activate() + if ok { + return action, broker, nil + } + case '\r', '\n', ' ': + action, broker, ok := state.activate() + if ok { + return action, broker, nil + } + case '\t': + state.tab() + case 'q', 'Q': + return "cancel", setupConfiguredBroker{}, nil + case 0x1b: + last, ok, err := setupReadEscapeSequence(reader) + if err != nil { + return "", setupConfiguredBroker{}, err + } + if !ok { + return "cancel", setupConfiguredBroker{}, nil + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + action, broker, ok := state.activate() + if ok { + return action, broker, nil + } + case 'D': + return "cancel", setupConfiguredBroker{}, nil + } } } - return setupSelection{ - Profiles: profiles, - Keys: keys, - IdentityName: strings.TrimSpace(s.identityName), - IdentityEmail: strings.TrimSpace(s.identityEmail), +} + +func (s *setupBrokerHomeState) rows() int { + rows := len(s.Brokers) + if len(s.CreateProviders) > 0 { + rows++ + } + if rows == 0 { + rows = 1 } + return rows } -func (s *setupDialogState) rows() int { - if s.createProvider != "" { - return len(s.createFields()) +func (s *setupBrokerHomeState) visibleRange() (int, int) { + const maxRows = 10 + rows := s.rows() + if s.Cursor < s.Scroll { + s.Scroll = s.Cursor } - return len(s.visibleItems()) + if s.Cursor >= s.Scroll+maxRows { + s.Scroll = s.Cursor - maxRows + 1 + } + if s.Scroll < 0 { + s.Scroll = 0 + } + if s.Scroll > rows-maxRows { + s.Scroll = maxSetupDialogInt(0, rows-maxRows) + } + end := minSetupDialogInt(s.Scroll+maxRows, rows) + return s.Scroll, end } -func (s *setupDialogState) up() { - if s.editingCreate || s.editingIdentity { +func (s *setupBrokerHomeState) up() { + if s.rows() == 0 { return } - if s.rows() == 0 { + s.Button = -1 + s.Message = "" + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 return } - s.button = -1 - s.message = "" - if s.cursor == 0 { - s.cursor = s.rows() - 1 + s.Cursor-- +} + +func (s *setupBrokerHomeState) down() { + if s.rows() == 0 { return } - s.cursor-- + s.Button = -1 + s.Message = "" + s.Cursor = (s.Cursor + 1) % s.rows() } -func (s *setupDialogState) down() { - if s.editingCreate || s.editingIdentity { +func (s *setupBrokerHomeState) tab() { + s.Message = "" + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 return } - if s.rows() == 0 { + if s.Button < 0 { + s.Button = 0 return } - s.button = -1 - s.message = "" - s.cursor = (s.cursor + 1) % s.rows() + s.Button = (s.Button + 1) % 2 } -func (s *setupDialogState) activate() (setupSelection, bool) { - if s.createProvider != "" { - if s.button == 0 { - return s.deployCreateProfile() - } - if s.button == 1 { - s.cancelCreateProfile() - return setupSelection{}, false +func (s *setupBrokerHomeState) activate() (string, setupConfiguredBroker, bool) { + if s.Button == 0 { + return "new", setupConfiguredBroker{}, true + } + if s.Button == 1 { + return "cancel", setupConfiguredBroker{}, true + } + if len(s.Brokers) == 0 { + if len(s.CreateProviders) == 0 { + s.Message = "Install gcloud or AWS CLI to create a broker." + return "", setupConfiguredBroker{}, false } - s.editingCreate = true - s.editOriginal = s.createFieldValue() - s.message = "" - return setupSelection{}, false + return "new", setupConfiguredBroker{}, true } - if s.button == 0 { - return s.deploy() + if s.Cursor < len(s.Brokers) { + return "broker", s.Brokers[s.Cursor], true } - if s.button == 1 { - return setupSelection{}, false + return "new", setupConfiguredBroker{}, true +} + +func renderSetupBrokerHomeFrame(state setupBrokerHomeState, rawMode bool) string { + rendered := renderSetupBrokerHomeWithStyle(state, rawMode) + if !rawMode { + return rendered } - items := s.visibleItems() - if s.cursor < 0 || s.cursor >= len(items) { - return setupSelection{}, false + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupBrokerHomeWithStyle(state setupBrokerHomeState, style bool) string { + width := setupDialogDynamicWidth(58, setupBreadcrumb("Broker setups"), "Up/Down move Right/Enter select Tab buttons") + for _, broker := range state.Brokers { + width = setupDialogDynamicWidth(width, setupBrokerQualifiedName(broker)+" "+firstNonEmpty(broker.Detail, strings.TrimPrefix(strings.TrimPrefix(broker.BrokerURL, "https://"), "http://"))) } - s.message = "" - item := items[s.cursor] - switch item.Kind { - case "identity-name", "identity-email": - s.editingIdentity = true - s.editOriginal = s.identityFieldValue() - s.message = "" - case "create-profile": - s.createProvider = item.Provider - s.createName = firstNonEmpty(s.defaultCreateByProvider[item.Provider], s.defaultCreate) + var lines []string + lines = append(lines, + setupDialogBorder(width), + setupDialogTitleRow(width), + setupDialogBorder(width), + setupDialogRowWidth(setupBreadcrumb("Broker setups"), width), + setupDialogRowWidth("", width), + ) + start, end := state.visibleRange() + rows := state.rows() + for row := start; row < end; row++ { + marker := " " + if state.Button < 0 && state.Cursor == row { + marker = ">" + } + rowStyle := setupDialogSectionStyle(style, state.Button < 0 && state.Cursor == row) + switch { + case row < len(state.Brokers): + broker := state.Brokers[row] + detail := firstNonEmpty(broker.Detail, strings.TrimPrefix(strings.TrimPrefix(broker.BrokerURL, "https://"), "http://")) + lines = append(lines, setupDialogRowStyledWidth(fmt.Sprintf("%s %-24s %s", marker, setupBrokerQualifiedName(broker), detail), width, rowStyle)) + case len(state.CreateProviders) > 0: + lines = append(lines, setupDialogRowStyledWidth(fmt.Sprintf("%s new broker", marker), width, rowStyle)) + default: + lines = append(lines, setupDialogRowStyledWidth(" no brokers configured", width, rowStyle)) + } + } + if rows > 10 { + lines = append(lines, setupDialogRowWidth(setupBrokerScrollBar(start, end, rows), width)) + } + if state.Message != "" { + lines = append(lines, setupDialogRowStyledWidth(state.Message, width, setupDialogANSI(style, "33"))) + } + okStyle := "" + exitStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + exitStyle = "\x1b[44;97m" + } + lines = append(lines, + setupDialogRowWidth("", width), + setupDialogBorder(width), + setupDialogRowWidth(setupDialogButton("[ New ]", okStyle)+" "+setupDialogButton("[ Exit ]", exitStyle), width), + setupDialogRowWidth("Up/Down move Right/Enter select Tab buttons", width), + setupDialogRowWidth("Left/Esc cancel Ctrl-C cancel", width), + setupDialogBorder(width), + ) + return strings.Join(lines, "\n") + "\n" +} + +func setupBrokerScrollBar(start, end, total int) string { + const width = 24 + if total <= 0 { + return "scroll [" + strings.Repeat("-", width) + "]" + } + thumb := maxSetupDialogInt(1, width*(end-start)/total) + pos := 0 + if total > end-start { + pos = (width - thumb) * start / (total - (end - start)) + } + var b strings.Builder + b.WriteString("scroll [") + for i := 0; i < width; i++ { + if i >= pos && i < pos+thumb { + b.WriteByte('#') + } else { + b.WriteByte('-') + } + } + b.WriteString(fmt.Sprintf("] %d-%d of %d", start+1, end, total)) + return b.String() +} + +func runSetupBrokerActionWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, broker setupConfiguredBroker) (string, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return "", err + } + defer restore() + state := setupBrokerActionState{Broker: broker, Button: -1} + for { + fmt.Fprint(stdout, renderSetupBrokerActionFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return "cancel", nil + } + return "", err + } + switch b { + case 0x03: + return "", errors.New("setup canceled") + case 0x04: + action, ok := state.activate() + if ok { + return action, nil + } + case '\r', '\n', ' ': + action, ok := state.activate() + if ok { + return action, nil + } + case '\t': + state.tab() + case 'q', 'Q': + return "cancel", nil + case 0x1b: + last, ok, err := setupReadEscapeSequence(reader) + if err != nil { + return "", err + } + if !ok { + return "back", nil + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + action, ok := state.activate() + if ok { + return action, nil + } + case 'D': + return "back", nil + } + } + } +} + +func (s *setupBrokerActionState) rows() int { return 4 } + +func (s *setupBrokerActionState) up() { + s.Button = -1 + s.Message = "" + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 + return + } + s.Cursor-- +} + +func (s *setupBrokerActionState) down() { + s.Button = -1 + s.Message = "" + s.Cursor = (s.Cursor + 1) % s.rows() +} + +func (s *setupBrokerActionState) tab() { + s.Message = "" + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 + return + } + if s.Button < 0 { + s.Button = 0 + return + } + s.Button = (s.Button + 1) % 2 +} + +func (s *setupBrokerActionState) activate() (string, bool) { + if s.Button == 1 { + return "back", true + } + if s.Button == 0 { + return "manage", true + } + switch s.Cursor { + case 0: + return "manage", true + case 1: + return "update", true + case 2: + return "delete", true + default: + return "back", true + } +} + +func renderSetupBrokerActionFrame(state setupBrokerActionState, rawMode bool) string { + rendered := renderSetupBrokerActionWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupBrokerActionWithStyle(state setupBrokerActionState, style bool) string { + const width = 76 + actions := []struct { + Label string + Help string + }{ + {"manage broker", "users, teams, invites, and ownership commands"}, + {"update broker", "redeploy stack/function for this profile and region"}, + {"delete broker", "decommission broker infrastructure"}, + {"back", "return to shell without changes"}, + } + var lines []string + lines = append(lines, + setupDialogBorder(width), + setupDialogRowWidth("BUCKETGIT SETUP", width), + setupDialogBorder(width), + setupDialogRowWidth("Broker "+setupBrokerQualifiedName(state.Broker), width), + setupDialogRowWidth(strings.TrimPrefix(strings.TrimPrefix(state.Broker.BrokerURL, "https://"), "http://"), width), + setupDialogRowWidth("", width), + ) + for i, action := range actions { + marker := " " + if state.Button < 0 && state.Cursor == i { + marker = ">" + } + rowStyle := setupDialogSectionStyle(style, state.Button < 0 && state.Cursor == i) + lines = append(lines, setupDialogWrappedActionRows(marker, action.Label, action.Help, 15, width, rowStyle)...) + } + okStyle := "" + exitStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + exitStyle = "\x1b[44;97m" + } + lines = append(lines, + setupDialogRowWidth("", width), + setupDialogBorder(width), + setupDialogRowWidth(setupDialogButton("[ Manage ]", okStyle)+" "+setupDialogButton("[ Back ]", exitStyle), width), + setupDialogRowWidth("Up/Down move Right/Enter select Tab buttons", width), + setupDialogRowWidth("Left/Esc back Ctrl-C cancel", width), + setupDialogBorder(width), + ) + return strings.Join(lines, "\n") + "\n" +} + +func setupBrokerManageActions() []setupBrokerManageAction { + return []setupBrokerManageAction{ + {ID: "users-manage", Label: "manage broker users", Help: "invite users and manage broker roles"}, + {ID: "teams-manage", Label: "team management", Help: "create teams, manage members, and repository access"}, + {ID: "back", Label: "back", Help: "return to shell"}, + } +} + +func runSetupBrokerManageWithRaw(base config, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, broker setupConfiguredBroker) error { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return err + } + defer restore() + state := setupBrokerManageState{Broker: broker, Button: -1} + cfg := setupBrokerConfig(base, broker) + for { + fmt.Fprint(stdout, renderSetupBrokerManageFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + switch b { + case 0x03: + return errors.New("setup canceled") + case 0x04: + action, ok := state.activate() + if ok { + if action == "back" { + return errSetupBack + } + msg, err := runSetupBrokerManageAction(cfg, broker, action, reader, rawInput, stdout) + if err != nil { + state.Message = err.Error() + } else { + state.Message = msg + } + } + case '\r', '\n', ' ': + action, ok := state.activate() + if ok { + if action == "back" { + return errSetupBack + } + msg, err := runSetupBrokerManageAction(cfg, broker, action, reader, rawInput, stdout) + if err != nil { + state.Message = err.Error() + } else { + state.Message = msg + } + } + case '\t': + state.tab() + case 'q', 'Q': + return nil + case 0x1b: + last, ok, err := setupReadEscapeSequence(reader) + if err != nil { + return err + } + if !ok { + return errSetupBack + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + action, ok := state.activate() + if ok { + if action == "back" { + return errSetupBack + } + msg, err := runSetupBrokerManageAction(cfg, broker, action, reader, rawInput, stdout) + if err != nil { + state.Message = err.Error() + } else { + state.Message = msg + } + } + case 'D': + return errSetupBack + } + } + } +} + +func (s *setupBrokerManageState) rows() int { return len(setupBrokerManageActions()) } + +func (s *setupBrokerManageState) visibleRange() (int, int) { + const maxRows = 10 + rows := s.rows() + if s.Cursor < s.Scroll { + s.Scroll = s.Cursor + } + if s.Cursor >= s.Scroll+maxRows { + s.Scroll = s.Cursor - maxRows + 1 + } + if s.Scroll < 0 { + s.Scroll = 0 + } + if s.Scroll > rows-maxRows { + s.Scroll = maxSetupDialogInt(0, rows-maxRows) + } + return s.Scroll, minSetupDialogInt(s.Scroll+maxRows, rows) +} + +func (s *setupBrokerManageState) up() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 + return + } + s.Cursor-- +} + +func (s *setupBrokerManageState) down() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + s.Cursor = (s.Cursor + 1) % s.rows() +} + +func (s *setupBrokerManageState) tab() { + s.Message = "" + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 + return + } + if s.Button < 0 { + s.Button = 0 + return + } + s.Button = (s.Button + 1) % 2 +} + +func (s *setupBrokerManageState) activate() (string, bool) { + if s.Button == 1 { + return "back", true + } + actions := setupBrokerManageActions() + if s.Button == 0 { + if s.Cursor >= 0 && s.Cursor < len(actions) { + return actions[s.Cursor].ID, true + } + return "back", true + } + if s.Cursor >= 0 && s.Cursor < len(actions) { + return actions[s.Cursor].ID, true + } + return "", false +} + +func renderSetupBrokerManageFrame(state setupBrokerManageState, rawMode bool) string { + rendered := renderSetupBrokerManageWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupBrokerManageWithStyle(state setupBrokerManageState, style bool) string { + const width = 76 + actions := setupBrokerManageActions() + start, end := state.visibleRange() + var lines []string + lines = append(lines, + setupDialogBorder(width), + setupDialogRowWidth("BUCKETGIT SETUP", width), + setupDialogBorder(width), + setupDialogRowWidth("Manage "+setupBrokerQualifiedName(state.Broker), width), + setupDialogRowWidth(strings.TrimPrefix(strings.TrimPrefix(state.Broker.BrokerURL, "https://"), "http://"), width), + setupDialogRowWidth("", width), + ) + for i := start; i < end; i++ { + action := actions[i] + marker := " " + if state.Button < 0 && state.Cursor == i { + marker = ">" + } + rowStyle := setupDialogSectionStyle(style, state.Button < 0 && state.Cursor == i) + lines = append(lines, setupDialogWrappedActionRows(marker, action.Label, action.Help, 20, width, rowStyle)...) + } + if len(actions) > 10 { + lines = append(lines, setupDialogRowWidth(setupBrokerScrollBar(start, end, len(actions)), width)) + } + if state.Message != "" { + lines = append(lines, setupDialogRowStyledWidth(state.Message, width, setupDialogANSI(style, "33"))) + } + okStyle := "" + backStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + backStyle = "\x1b[44;97m" + } + lines = append(lines, + setupDialogRowWidth("", width), + setupDialogBorder(width), + setupDialogRowWidth(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Back ]", backStyle), width), + setupDialogRowWidth("Up/Down move Right/Enter select Tab buttons", width), + setupDialogRowWidth("Left/Esc back Ctrl-C cancel", width), + setupDialogBorder(width), + ) + return strings.Join(lines, "\n") + "\n" +} + +func runSetupBrokerManageAction(cfg config, broker setupConfiguredBroker, action string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + switch action { + case "users-manage": + msg, err := runSetupBrokerUsersWithRaw(cfg, broker, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + return "No changes made.", nil + } + return msg, err + case "teams-manage": + return runSetupTeamManagementWithRaw(cfg, reader, rawInput, stdout) + case "back": + return "No changes made.", nil + default: + return "", fmt.Errorf("unknown broker management action %q", action) + } +} + +func runSetupBrokerUsersWithRaw(cfg config, broker setupConfiguredBroker, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + title := setupBreadcrumb("Manage broker users") + for { + choices, err := setupBrokerUserManagementChoices(cfg) + if err != nil { + return "", err + } + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + if action == "invite-user" { + msg, err := runSetupBrokerUserInviteWithRaw(cfg, broker, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } + if action == "noop" { + continue + } + if user, ok := strings.CutPrefix(action, "user:"); ok { + msg, err := runSetupBrokerUserWithRaw(cfg, broker, user, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + continue + } + return msg, err + } + } +} + +func setupBrokerUserManagementChoices(cfg config) ([]setupChoice, error) { + users, err := setupBrokerUsers(cfg) + if err != nil { + return nil, err + } + choices := []setupChoice{{Label: "invite user", Value: "invite-user", Help: "create a broker invite command"}} + if len(users) == 0 { + choices = append(choices, setupChoice{Label: "no broker users", Value: "noop", Group: "users:"}) + } else { + sort.Slice(users, func(i, j int) bool { + return strings.ToLower(firstNonEmpty(users[i].Username, users[i].ID)) < strings.ToLower(firstNonEmpty(users[j].Username, users[j].ID)) + }) + for _, user := range users { + username := firstNonEmpty(user.Username, user.ID) + if username == "" { + continue + } + label := username + if user.Pending || len(user.Keys) == 0 { + label += " *" + } + choices = append(choices, setupChoice{Label: label, Value: "user:" + username, Help: setupBrokerUserStatus(user), Group: "users:"}) + } + } + choices = append(choices, setupChoice{Label: "back", Value: "back", Help: "return to broker management"}) + return choices, nil +} + +func runSetupBrokerUserInviteWithRaw(cfg config, broker setupConfiguredBroker, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + var out bytes.Buffer + fields, ok, err := runSetupTextFormWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage broker users", "Invite user"), []setupTextField{ + {Label: "Username", Required: true}, + }) + if err != nil || !ok { + return "No changes made.", err + } + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage broker users", fields[0], "Role"), setupBrokerUserRoleChoices(), "user") + if err != nil || !ok { + return "No changes made.", err + } + if err := brokerAdminCommandWithInput(cfg, []string{"invite-broker-user", "--broker", broker.BrokerURL, "--user", fields[0], "--role", role}, strings.NewReader(""), &out); err != nil { + return "", err + } + return runSetupPlainCommandOutputWithRaw(reader, stdout, "Create user invite", setupAcceptCommandFromOutput(out.String())) +} + +func runSetupBrokerUserWithRaw(cfg config, broker setupConfiguredBroker, username string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + for { + user, ok, err := setupBrokerUserByName(cfg, username) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("broker user %s not found", username) + } + choices := setupBrokerUserActionChoices(user) + action, selected, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage broker users", username), choices, "") + if err != nil { + return "", err + } + if !selected || action == "back" { + return "", errSetupBack + } + msg, err := runSetupBrokerUserAction(cfg, broker, user, action, reader, rawInput, stdout) + if err != nil { + return "", err + } + if action == "delete" { + if _, err := runSetupBrokerOutputWithRaw(reader, rawInput, stdout, "Delete broker user", msg); err != nil { + return "", err + } + return "No changes made.", errSetupBack + } + if msg == "No changes made." { + continue + } + return msg, nil + } +} + +func setupBrokerUserActionChoices(user brokerUserInfo) []setupChoice { + if user.BrokerRole == "owner" { + return []setupChoice{ + {Label: "transfer ownership", Value: "transfer-owner", Help: "create an ownership transfer command"}, + {Label: "back", Value: "back", Help: "return to broker users"}, + } + } + choices := []setupChoice{{Label: "edit role", Value: "edit-role", Help: "change broker role"}} + if user.Suspended { + choices = append(choices, setupChoice{Label: "unsuspend user", Value: "unsuspend", Help: "restore broker access"}) + } else { + choices = append(choices, setupChoice{Label: "suspend user", Value: "suspend", Help: "deny broker access"}) + } + if user.Pending || len(user.Keys) == 0 { + choices = append(choices, setupChoice{Label: "cancel invite", Value: "cancel-invite", Help: "cancel pending broker invite"}) + } + choices = append(choices, setupChoice{Label: "delete user", Value: "delete", Help: "remove broker user and repository/team access"}) + choices = append(choices, setupChoice{Label: "back", Value: "back", Help: "return to broker users"}) + return choices +} + +func runSetupBrokerUserAction(cfg config, broker setupConfiguredBroker, user brokerUserInfo, action string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + var out bytes.Buffer + username := firstNonEmpty(user.Username, user.ID) + role := firstNonEmpty(user.BrokerRole, "user") + switch action { + case "transfer-owner": + repo, ok, err := runSetupRepoSelect(cfg, reader, rawInput, stdout, setupBreadcrumb("Manage broker users", username, "Transfer ownership")) + if err != nil || !ok { + return "No changes made.", err + } + if err := brokerAdminCommandWithInput(cfg, []string{"confirm-ownership-transfer", "--broker", broker.BrokerURL, repo}, strings.NewReader(""), &out); err != nil { + return "", err + } + return runSetupPlainCommandOutputWithRaw(reader, stdout, "Owner transfer", setupAcceptCommandFromOutput(out.String())) + case "edit-role": + nextRole, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage broker users", username, "Role"), setupBrokerUserRoleChoices(), role) + if err != nil || !ok { + return "No changes made.", err + } + if err := brokerAdminCommandWithInput(cfg, []string{"broker-users", "upsert", username, "--role", nextRole}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil + case "suspend": + if err := brokerAdminCommandWithInput(cfg, []string{"broker-users", "upsert", username, "--role", role, "--suspended", "true"}, strings.NewReader(""), &out); err != nil { + return "", err + } + return "suspended broker user " + username, nil + case "unsuspend": + if err := brokerAdminCommandWithInput(cfg, []string{"broker-users", "upsert", username, "--role", role, "--suspended", "false"}, strings.NewReader(""), &out); err != nil { + return "", err + } + return "unsuspended broker user " + username, nil + case "cancel-invite": + if err := brokerAdminCommandWithInput(cfg, []string{"cancel-broker-invite", "--broker", broker.BrokerURL, "--user", username}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil + case "delete": + if err := brokerAdminCommandWithInput(cfg, []string{"broker-users", "delete", username}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil + default: + return "", fmt.Errorf("unknown broker user action %q", action) + } +} + +func setupBrokerRepoConfig(cfg config, repo string) (config, error) { + logical, err := normalizeLogicalRepoName(repo) + if err != nil { + return cfg, err + } + cfg.logicalRepo = logical + cfg.prefix = logical + return cfg, nil +} + +func runSetupTeamManagementWithRaw(cfg config, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + for { + choices, err := setupTeamManagementChoices(cfg) + if err != nil { + return "", err + } + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Team management"), choices, "") + if err != nil || !ok || action == "back" { + return "No changes made.", err + } + switch action { + case "create": + msg, err := runSetupTeamCreateWithRaw(cfg, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + default: + team, ok := strings.CutPrefix(action, "team:") + if !ok { + return "", fmt.Errorf("unknown team management action %q", action) + } + teamInfo, _ := setupBrokerTeamInfo(cfg, team) + msg, err := runSetupManagedTeamWithRaw(cfg, team, setupTeamDisplayName(team, teamInfo), reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + continue + } + return msg, err + } + } +} + +func setupTeamManagementChoices(cfg config) ([]setupChoice, error) { + teams, err := setupBrokerTeamChoices(cfg) + if err != nil { + return nil, err + } + choices := []setupChoice{ + {Label: "create team", Value: "create", Help: "create a team namespace"}, + } + for _, team := range teams { + team.Value = "team:" + team.Value + choices = append(choices, team) + } + choices = append(choices, setupChoice{Label: "back", Value: "back", Help: "return to broker management"}) + return choices, nil +} + +func runSetupTeamCreateWithRaw(cfg config, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + var out bytes.Buffer + fields, ok, err := runSetupTextFormWithRaw(reader, rawInput, stdout, "Create team", []setupTextField{{Label: "Team name", Required: true}}) + if err != nil || !ok { + return "No changes made.", err + } + if err := brokerAdminCommandWithInput(cfg, []string{"teams", "create", fields[0]}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil +} + +func runSetupManagedTeamWithRaw(cfg config, teamID, teamName string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + teamLabel := firstNonEmpty(teamName, teamID) + title := setupBreadcrumb("Manage team", teamLabel) + for { + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, title, []setupChoice{ + {Label: "manage users", Value: "members-manage", Help: "add, edit, or remove team users"}, + {Label: "manage repositories", Value: "repos-manage", Help: "create and manage team repositories"}, + {Label: "back", Value: "back", Help: "return to team management"}, + }, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + msg, err := runSetupManagedTeamAction(cfg, teamID, teamLabel, action, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } +} + +func runSetupManagedTeamAction(cfg config, teamID, teamName, action string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + switch action { + case "members-manage": + msg, err := runSetupManagedTeamUsersWithRaw(cfg, teamID, teamName, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + return "No changes made.", nil + } + return msg, err + case "repos-manage": + msg, err := runSetupManagedTeamRepositoriesWithRaw(cfg, teamID, teamName, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + return "No changes made.", nil + } + return msg, err + case "repos-list": + if _, err := runSetupBrokerOutputWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories"), setupFormatTeamRepositories(cfg, teamID)); err != nil { + return "", err + } + return "No changes made.", nil + default: + return "", fmt.Errorf("unknown team management action %q", action) + } +} + +func runSetupManagedTeamUsersWithRaw(cfg config, teamID, teamName string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + title := setupBreadcrumb("Manage team", teamName, "Manage users") + for { + choices, err := setupTeamUserManagementChoices(cfg, teamID) + if err != nil { + return "", err + } + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + if action == "add-user" { + msg, err := runSetupManagedTeamUserAdd(cfg, teamID, teamName, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } + if action == "noop" { + continue + } + if user, ok := strings.CutPrefix(action, "user:"); ok { + msg, err := runSetupManagedTeamUserWithRaw(cfg, teamID, teamName, user, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + continue + } + return msg, err + } + } +} + +func setupTeamUserManagementChoices(cfg config, teamID string) ([]setupChoice, error) { + team, err := setupBrokerTeamInfo(cfg, teamID) + if err != nil { + return nil, err + } + choices := []setupChoice{{Label: "add user", Value: "add-user", Help: "add a user with a team role cap"}} + if len(team.Members) == 0 { + choices = append(choices, setupChoice{Label: "no team users", Value: "noop", Group: "users:"}) + } else { + for _, member := range team.Members { + user := firstNonEmpty(member.Username, member.UserID) + if user == "" { + continue + } + choices = append(choices, setupChoice{Label: user, Value: "user:" + user, Help: "role cap " + firstNonEmpty(member.Role, "read"), Group: "users:"}) + } + } + choices = append(choices, setupChoice{Label: "back", Value: "back", Help: "return to team"}) + return choices, nil +} + +func runSetupManagedTeamUserAdd(cfg config, teamID, teamName string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + var out bytes.Buffer + user, ok, err := runSetupAvailableTeamUserSelect(cfg, teamID, reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Manage users", "Add user")) + if err != nil || !ok { + return "No changes made.", err + } + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Manage users", user, "Team role cap"), setupRepoRoleCapChoices(), "developer") + if err != nil || !ok { + return "No changes made.", err + } + if err := brokerAdminCommandWithInput(cfg, []string{"teams", "member", "add", teamID, user, "--role", role}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil +} + +func runSetupManagedTeamUserWithRaw(cfg config, teamID, teamName, user string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + title := setupBreadcrumb("Manage team", teamName, "Manage users", user) + for { + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, title, []setupChoice{ + {Label: "edit role cap", Value: "edit", Help: "change this user's team role cap"}, + {Label: "remove user", Value: "remove", Help: "remove this user from the team"}, + {Label: "back", Value: "back", Help: "return to users"}, + }, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + msg, err := runSetupManagedTeamUserAction(cfg, teamID, teamName, user, action, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } +} + +func runSetupManagedTeamUserAction(cfg config, teamID, teamName, user, action string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + var out bytes.Buffer + switch action { + case "edit": + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Manage users", user, "Team role cap"), setupRepoRoleCapChoices(), "developer") + if err != nil || !ok { + return "No changes made.", err + } + if err := brokerAdminCommandWithInput(cfg, []string{"teams", "member", "add", teamID, user, "--role", role}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil + case "remove": + if err := brokerAdminCommandWithInput(cfg, []string{"teams", "member", "remove", teamID, user}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil + default: + return "", fmt.Errorf("unknown team user action %q", action) + } +} + +func runSetupManagedTeamRepositoriesWithRaw(cfg config, teamID, teamName string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + title := setupBreadcrumb("Manage team", teamName, "Repositories") + for { + choices, err := setupBrokerTeamRepositoryMenuChoices(cfg, teamID) + if err != nil { + return "", err + } + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + if action == "create" { + msg, err := runSetupTeamRepositoryCreateWithRaw(cfg, teamID, teamName, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } + if repo, ok := strings.CutPrefix(action, "repo:"); ok { + msg, err := runSetupManagedTeamRepositoryWithRaw(cfg, teamID, teamName, repo, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + continue + } + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } + } +} + +func runSetupTeamRepositoryCreateWithRaw(cfg config, teamID, teamName string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + fields, ok, err := runSetupTextFormWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", "Create repository"), []setupTextField{ + {Label: "Repository", Required: true}, + }) + if err != nil || !ok { + return "No changes made.", err + } + logical, err := normalizeLogicalRepoName(fields[0]) + if err != nil { + return "", err + } + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", logicalRepoDisplayName(logical), "Role cap"), setupRepoRoleCapChoices(), "developer") + if err != nil || !ok { + return "No changes made.", err + } + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return "", err + } + actionCfg, err := setupBrokerRepoConfig(cfg, logical) + if err != nil { + return "", err + } + actionCfg.provider = firstNonEmpty(actionCfg.provider, cfg.provider, "gcs") + actionCfg.brokerURL = brokerURL + actionCfg.teamID = teamID + req := brokerRepoAdminRequest{Repo: repoForBroker(actionCfg), Role: role} + if err := brokerPost(brokerURL, "/repos/create", req, nil); err != nil { + return "", err + } + return fmt.Sprintf("created repository %s and granted %s %s access", logicalRepoDisplayName(logical), firstNonEmpty(teamName, teamID), role), nil +} + +func setupBrokerTeamRepositoryMenuChoices(cfg config, teamID string) ([]setupChoice, error) { + repos, err := setupBrokerTeamRepoChoices(cfg, teamID) + if err != nil { + return nil, err + } + choices := []setupChoice{ + {Label: "create repository", Value: "create", Help: "create a repository for this team"}, + } + for _, repo := range repos { + repo.Value = "repo:" + repo.Value + choices = append(choices, repo) + } + choices = append(choices, setupChoice{Label: "back", Value: "back", Help: "return to team"}) + return choices, nil +} + +func runSetupManagedTeamRepositoryWithRaw(cfg config, teamID, teamName, repo string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + repoName := logicalRepoDisplayName(repo) + for { + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName), []setupChoice{ + {Label: "manage access", Value: "access-manage", Help: "users, invites, and team sharing"}, + {Label: "edit role cap", Value: "repos-edit", Help: "change this team's repository role cap"}, + {Label: "remove access", Value: "repos-remove", Help: "detach this repository from this team"}, + {Label: "back", Value: "back", Help: "return to repositories"}, + }, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + msg, err := runSetupManagedTeamRepositoryAction(cfg, teamID, teamName, repo, action, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } +} + +func runSetupManagedTeamRepositoryAction(cfg config, teamID, teamName, repo, action string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + var out bytes.Buffer + repoName := logicalRepoDisplayName(repo) + switch action { + case "access-manage": + msg, err := runSetupManagedTeamRepositoryAccessWithRaw(cfg, teamID, teamName, repo, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + return "No changes made.", nil + } + return msg, err + case "invite-user": + user, ok, err := runSetupAvailableRepoInviteUserSelect(cfg, repo, teamID, reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName, "Invite user")) + if err != nil || !ok { + return "No changes made.", err + } + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName, "Role"), setupRepoRoleChoices(), "developer") + if err != nil || !ok { + return "No changes made.", err + } + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return "", err + } + brokerRepo, err := setupBrokerTeamRepoForAction(cfg, repo, teamID) + if err != nil { + return "", err + } + var resp brokerOwnerTransferResponse + if err := brokerPost(brokerURL, "/keys/invite/create", brokerOwnerTransferRequest{Repo: brokerRepo, BrokerURL: brokerURL, User: user, Role: role}, &resp); err != nil { + return "", err + } + return runSetupPlainCommandOutputWithRaw(reader, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName, "Invite user"), resp.AcceptCommand) + case "cancel-invite": + brokerRepo, err := setupBrokerTeamRepoForAction(cfg, repo, teamID) + if err != nil { + return "", err + } + user, ok, err := runSetupPendingRepoInviteSelect(cfg, brokerRepo, reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName, "Pending invite")) + if err != nil || !ok { + return "No changes made.", err + } + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return "", err + } + if err := brokerPost(brokerURL, "/keys/invite/cancel", brokerOwnerTransferRequest{Repo: brokerRepo, User: user}, nil); err != nil { + return "", err + } + fmt.Fprintf(&out, "cancelled invite for %s on %s\n", user, brokerRepo.Logical) + return strings.TrimSpace(out.String()), nil + case "grant-team": + targetTeamID, ok, err := runSetupAvailableRepoTeamSelect(cfg, repo, teamID, reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName, "Grant team access")) + if err != nil || !ok { + return "No changes made.", err + } + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName, "Team role cap"), setupRepoRoleCapChoices(), "developer") + if err != nil || !ok { + return "No changes made.", err + } + actionCfg, err := setupBrokerRepoConfig(cfg, repo) + if err != nil { + return "", err + } + actionCfg.provider = firstNonEmpty(actionCfg.provider, cfg.provider, "gcs") + actionCfg.teamID = strings.TrimSpace(teamID) + if err := brokerAdminCommandWithInput(actionCfg, []string{"teams", "repo", "add", targetTeamID, role}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil + case "repos-edit": + return runSetupTeamRepoAccessUpsert(cfg, teamID, teamName, repo, reader, rawInput, stdout) + case "repos-remove": + actionCfg, err := setupBrokerRepoConfig(cfg, repo) + if err != nil { + return "", err + } + if err := brokerAdminCommandWithInput(actionCfg, []string{"teams", "repo", "remove", teamID}, strings.NewReader(""), &out); err != nil { + return "", err + } + return strings.TrimSpace(out.String()), nil + default: + return "", fmt.Errorf("unknown team repository action %q", action) + } +} + +func runSetupManagedTeamRepositoryAccessWithRaw(cfg config, teamID, teamName, repo string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + repoName := logicalRepoDisplayName(repo) + title := setupBreadcrumb("Manage team", teamName, "Repositories", repoName, "Manage access") + for { + choices, err := setupRepoAccessManagementChoices(cfg, repo, teamID) + if err != nil { + return "", err + } + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + if action == "invite-user" || action == "cancel-invite" || action == "grant-team" { + msg, err := runSetupManagedTeamRepositoryAction(cfg, teamID, teamName, repo, action, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } + if action == "noop" { + continue + } + if user, ok := strings.CutPrefix(action, "user:"); ok { + msg, err := runSetupManagedTeamRepositoryUserWithRaw(cfg, teamID, teamName, repo, user, reader, rawInput, stdout) + if errors.Is(err, errSetupBack) { + continue + } + return msg, err + } + } +} + +func setupRepoAccessManagementChoices(cfg config, repo, teamID string) ([]setupChoice, error) { + choices := []setupChoice{ + {Label: "invite user", Value: "invite-user", Help: "create an invite for this repository"}, + {Label: "cancel invite", Value: "cancel-invite", Help: "cancel a pending invite by username"}, + {Label: "grant team access", Value: "grant-team", Help: "share this repository with another team"}, + } + users, err := setupRepoUsers(cfg, repo, teamID) + if err != nil { + return nil, err + } + if len(users) > 0 { + for _, user := range users { + label := user.User + help := "role " + user.Role + if user.KeyCount > 1 { + help = fmt.Sprintf("role %s, %d keys", user.Role, user.KeyCount) + } + choices = append(choices, setupChoice{Label: label, Value: "user:" + user.User, Help: help, Group: "users:"}) + } + } else { + choices = append(choices, setupChoice{Label: "no repository users", Value: "noop", Help: "", Group: "users:"}) + } + choices = append(choices, setupChoice{Label: "back", Value: "back", Help: "return to repository"}) + return choices, nil +} + +func runSetupAvailableRepoTeamSelect(cfg config, repo, currentTeamID string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + choices, err := setupAvailableRepoTeamChoices(cfg, repo, currentTeamID) + if err != nil { + return "", false, err + } + if len(choices) == 0 { + if _, err := runSetupBrokerOutputWithRaw(reader, rawInput, stdout, title, "No teams are available to grant access."); err != nil { + return "", false, err + } + return "", false, nil + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") +} + +func setupAvailableRepoTeamChoices(cfg config, repo, currentTeamID string) ([]setupChoice, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return nil, err + } + var teamsResp brokerTeamsResponse + if err := brokerPost(brokerURL, "/teams/list", brokerRepoAdminRequest{}, &teamsResp); err != nil { + return nil, err + } + repos, err := setupBrokerRepos(cfg) + if err != nil { + return nil, err + } + logical, err := normalizeLogicalRepoName(repo) + if err != nil { + return nil, err + } + attached := map[string]struct{}{} + for _, item := range repos { + itemLogical := firstNonEmpty(item.Logical, item.Repo.Logical) + if itemLogical != logical { + continue + } + for _, grant := range item.Teams { + id := firstNonEmpty(grant.ID, grant.TeamID) + if id != "" { + attached[id] = struct{}{} + } + } + if item.Repo.TeamID != "" { + attached[item.Repo.TeamID] = struct{}{} + } + } + repoTeams, err := setupBrokerRepoTeamIDs(cfg, repo, currentTeamID) + if err != nil { + return nil, err + } + for _, id := range repoTeams { + attached[id] = struct{}{} + } + if currentTeamID != "" { + attached[currentTeamID] = struct{}{} + } + var choices []setupChoice + for _, team := range teamsResp.Teams { + id := strings.TrimSpace(team.ID) + if id == "" { + continue + } + if _, ok := attached[id]; ok { + continue + } + choices = append(choices, setupChoice{Label: setupTeamDisplayName(id, team), Value: id, Help: fmt.Sprintf("%d member(s)", len(team.Members))}) + } + sort.Slice(choices, func(i, j int) bool { return choices[i].Label < choices[j].Label }) + return choices, nil +} + +func setupBrokerRepoTeamIDs(cfg config, repo, teamID string) ([]string, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return nil, err + } + brokerRepo, err := setupBrokerTeamRepoForAction(cfg, repo, teamID) + if err != nil { + return nil, err + } + var resp struct { + Teams []brokerRepoTeamGrant `json:"teams"` + } + if err := brokerPost(brokerURL, "/repo/teams/list", brokerRepoInfoRequest{Repo: brokerRepo}, &resp); err != nil { + return nil, err + } + ids := make([]string, 0, len(resp.Teams)) + for _, grant := range resp.Teams { + id := firstNonEmpty(grant.ID, grant.TeamID) + if id != "" { + ids = append(ids, id) + } + } + return ids, nil +} + +type setupRepoUser struct { + User string + Role string + KeyCount int + PublicKey []string +} + +func setupRepoUsers(cfg config, repo, teamID string) ([]setupRepoUser, error) { + keys, err := setupRepoKeys(cfg, repo, teamID) + if err != nil { + return nil, err + } + byUser := map[string]*setupRepoUser{} + for _, key := range keys { + user := strings.TrimSpace(key.User) + if user == "" { + user = "unknown" + } + mapKey := strings.ToLower(user) + entry := byUser[mapKey] + if entry == nil { + entry = &setupRepoUser{User: user, Role: firstNonEmpty(key.Role, "read")} + byUser[mapKey] = entry + } + entry.KeyCount++ + entry.PublicKey = append(entry.PublicKey, key.PublicKey) + entry.Role = strongerSetupRepoRole(entry.Role, firstNonEmpty(key.Role, "read")) + } + users := make([]setupRepoUser, 0, len(byUser)) + for _, user := range byUser { + users = append(users, *user) + } + sort.Slice(users, func(i, j int) bool { return users[i].User < users[j].User }) + return users, nil +} + +func setupRepoKeys(cfg config, repo, teamID string) ([]brokerKey, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return nil, err + } + actionCfg, err := setupBrokerRepoConfig(cfg, repo) + if err != nil { + return nil, err + } + actionCfg.provider = firstNonEmpty(actionCfg.provider, cfg.provider, "gcs") + actionCfg.teamID = strings.TrimSpace(teamID) + return brokerListKeys(brokerURL, actionCfg) +} + +func strongerSetupRepoRole(a, b string) string { + if setupRepoRoleRank(b) > setupRepoRoleRank(a) { + return b + } + return a +} + +func setupRepoRoleRank(role string) int { + switch normalizeBrokerRole(role) { + case "owner": + return 6 + case "admin": + return 5 + case "maintainer": + return 4 + case "developer": + return 3 + case "triage": + return 2 + case "read": + return 1 + default: + return 0 + } +} + +func runSetupManagedTeamRepositoryUserWithRaw(cfg config, teamID, teamName, repo, user string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + repoName := logicalRepoDisplayName(repo) + title := setupBreadcrumb("Manage team", teamName, "Repositories", repoName, user) + for { + action, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, title, []setupChoice{ + {Label: "edit role cap", Value: "user-edit", Help: "change repository role for this user"}, + {Label: "suspend access", Value: "user-suspend", Help: "suspend this user's direct keys"}, + {Label: "remove access", Value: "user-remove", Help: "remove this user's direct keys"}, + {Label: "back", Value: "back", Help: "return to users"}, + }, "") + if err != nil { + return "", err + } + if !ok || action == "back" { + return "", errSetupBack + } + msg, err := runSetupManagedTeamRepositoryUserAction(cfg, teamID, teamName, repo, user, action, reader, rawInput, stdout) + if err != nil { + return "", err + } + if msg == "No changes made." { + continue + } + return msg, nil + } +} + +func runSetupManagedTeamRepositoryUserAction(cfg config, teamID, teamName, repo, user, action string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + repoName := logicalRepoDisplayName(repo) + keys, err := setupRepoKeysForUser(cfg, repo, teamID, user) + if err != nil { + return "", err + } + if len(keys) == 0 { + return "No repository access found for " + user + ".", nil + } + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return "", err + } + actionCfg, err := setupBrokerRepoConfig(cfg, repo) + if err != nil { + return "", err + } + actionCfg.provider = firstNonEmpty(actionCfg.provider, cfg.provider, "gcs") + actionCfg.teamID = strings.TrimSpace(teamID) + switch action { + case "user-edit": + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", teamName, "Repositories", repoName, user, "Role"), setupRepoRoleChoices(), "developer") + if err != nil || !ok { + return "No changes made.", err + } + for _, key := range keys { + if err := brokerPost(brokerURL, "/keys/remove", brokerKeyRequest{Repo: repoForBroker(actionCfg), Key: key.PublicKey}, nil); err != nil { + return "", err + } + } + publicKeys := make([]string, 0, len(keys)) + for _, key := range keys { + publicKeys = append(publicKeys, key.PublicKey) + } + if err := brokerAddKeysWithSource(brokerURL, actionCfg, user, role, "setup", publicKeys); err != nil { + return "", err + } + return fmt.Sprintf("updated %s on %s to %s", user, logicalRepoDisplayName(repo), role), nil + case "user-suspend", "user-remove": + path := "/keys/suspend" + verb := "suspended" + if action == "user-remove" { + path = "/keys/remove" + verb = "removed" + } + for _, key := range keys { + if err := brokerPost(brokerURL, path, brokerKeyRequest{Repo: repoForBroker(actionCfg), Key: key.PublicKey}, nil); err != nil { + return "", err + } + } + return fmt.Sprintf("%s access for %s on %s", verb, user, logicalRepoDisplayName(repo)), nil + default: + return "", fmt.Errorf("unknown repository user action %q", action) + } +} + +func setupRepoKeysForUser(cfg config, repo, teamID, user string) ([]brokerKey, error) { + keys, err := setupRepoKeys(cfg, repo, teamID) + if err != nil { + return nil, err + } + var out []brokerKey + for _, key := range keys { + if strings.EqualFold(strings.TrimSpace(key.User), strings.TrimSpace(user)) { + out = append(out, key) + } + } + return out, nil +} + +func runSetupPendingRepoInviteSelect(cfg config, repo brokerRepo, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + choices, err := setupPendingRepoInviteChoices(cfg, repo) + if err != nil { + return "", false, err + } + if len(choices) == 0 { + if _, err := runSetupBrokerOutputWithRaw(reader, rawInput, stdout, title, "No pending invites."); err != nil { + return "", false, err + } + return "", false, nil + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") +} + +func setupPendingRepoInviteChoices(cfg config, repo brokerRepo) ([]setupChoice, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return nil, err + } + var resp brokerRepoInvitesResponse + if err := brokerPost(brokerURL, "/keys/invite/list", brokerOwnerTransferRequest{Repo: repo}, &resp); err != nil { + return nil, err + } + choices := make([]setupChoice, 0, len(resp.Invites)) + for _, invite := range resp.Invites { + user := strings.TrimSpace(invite.User) + if user == "" { + continue + } + choices = append(choices, setupChoice{Label: user, Value: user, Help: firstNonEmpty(invite.Role, "read")}) + } + sort.Slice(choices, func(i, j int) bool { return choices[i].Label < choices[j].Label }) + return choices, nil +} + +func setupBrokerTeamRepoForAction(cfg config, repo, teamID string) (brokerRepo, error) { + actionCfg, err := setupBrokerRepoConfig(cfg, repo) + if err != nil { + return brokerRepo{}, err + } + actionCfg.provider = firstNonEmpty(actionCfg.provider, cfg.provider, "gcs") + actionCfg.teamID = strings.TrimSpace(teamID) + return repoForBroker(actionCfg), nil +} + +func runSetupTeamRepoAccessUpsert(cfg config, teamID, teamName, repo string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + return runSetupTeamRepoAccessUpsertMany(cfg, teamID, teamName, []string{repo}, reader, rawInput, stdout) +} + +func runSetupTeamRepoAccessUpsertMany(cfg config, teamID, teamName string, repos []string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer) (string, error) { + var out bytes.Buffer + if len(repos) == 0 { + return "No changes made.", nil + } + role, ok, err := runSetupSelectWithRaw(reader, rawInput, stdout, setupBreadcrumb("Manage team", firstNonEmpty(teamName, teamID), "Repository role cap"), setupRepoRoleCapChoices(), "developer") + if err != nil || !ok { + return "No changes made.", err + } + for _, repo := range repos { + actionCfg, err := setupBrokerRepoConfig(cfg, repo) + if err != nil { + return "", err + } + if err := brokerAdminCommandWithInput(actionCfg, []string{"teams", "repo", "add", teamID, role}, strings.NewReader(""), &out); err != nil { + return "", err + } + } + return strings.TrimSpace(out.String()), nil +} + +func setupBrokerUserRoleChoices() []setupChoice { + return []setupChoice{ + {Label: "user", Value: "user", Help: "normal broker user"}, + {Label: "admin", Value: "admin", Help: "broker administration"}, + } +} + +func setupRepoRoleChoices() []setupChoice { + return []setupChoice{ + {Label: "read", Value: "read", Help: "read repository"}, + {Label: "triage", Value: "triage", Help: "issues and PR triage"}, + {Label: "developer", Value: "developer", Help: "push branches"}, + {Label: "maintainer", Value: "maintainer", Help: "merge and maintain"}, + {Label: "admin", Value: "admin", Help: "repo administration"}, + } +} + +func setupRepoRoleCapChoices() []setupChoice { + choices := setupRepoRoleChoices() + choices = append(choices, setupChoice{Label: "owner", Value: "owner", Help: "owner-only repository actions"}) + return choices +} + +func runSetupTeamSelect(cfg config, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + teams, err := setupBrokerTeamChoices(cfg) + if err != nil { + return "", false, err + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, teams, "") +} + +func runSetupUserSelect(cfg config, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + users, err := setupBrokerUserChoices(cfg) + if err != nil { + return "", false, err + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, users, "") +} + +func runSetupAvailableRepoInviteUserSelect(cfg config, repo, teamID string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + users, err := setupAvailableRepoInviteUserChoices(cfg, repo, teamID) + if err != nil { + return "", false, err + } + if len(users) == 0 { + if _, err := runSetupBrokerOutputWithRaw(reader, rawInput, stdout, title, "No users are available to invite."); err != nil { + return "", false, err + } + return "", false, nil + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, users, "") +} + +func runSetupAvailableTeamUserSelect(cfg config, teamID string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + users, err := setupAvailableTeamUserChoices(cfg, teamID) + if err != nil { + return "", false, err + } + if len(users) == 0 { + if _, err := runSetupBrokerOutputWithRaw(reader, rawInput, stdout, title, "No users are available to add."); err != nil { + return "", false, err + } + return "", false, nil + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, users, "") +} + +func runSetupRepoSelect(cfg config, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + repos, err := setupBrokerRepoChoices(cfg) + if err != nil { + return "", false, err + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, repos, "") +} + +func runSetupRepoMultiSelect(cfg config, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) ([]string, bool, error) { + repos, err := setupBrokerRepoChoices(cfg) + if err != nil { + return nil, false, err + } + return runSetupMultiSelectWithRaw(reader, rawInput, stdout, title, repos) +} + +func runSetupTeamMemberSelect(cfg config, teamID string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + team, err := setupBrokerTeamInfo(cfg, teamID) + if err != nil { + return "", false, err + } + var choices []setupChoice + for _, member := range team.Members { + user := firstNonEmpty(member.Username, member.UserID) + if user == "" { + continue + } + choices = append(choices, setupChoice{Label: user, Value: user, Help: "team role cap " + firstNonEmpty(member.Role, "read")}) + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") +} + +func runSetupTeamRepoSelect(cfg config, teamID string, reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string) (string, bool, error) { + choices, err := setupBrokerTeamRepoChoices(cfg, teamID) + if err != nil { + return "", false, err + } + return runSetupSelectWithRaw(reader, rawInput, stdout, title, choices, "") +} + +func setupBrokerTeamChoices(cfg config) ([]setupChoice, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return nil, err + } + var resp brokerTeamsResponse + if err := brokerPost(brokerURL, "/teams/list", brokerRepoAdminRequest{}, &resp); err != nil { + return nil, err + } + var choices []setupChoice + for _, team := range resp.Teams { + choices = append(choices, setupChoice{Label: team.Name, Value: team.ID, Help: fmt.Sprintf("%d member(s)", len(team.Members))}) + } + sort.Slice(choices, func(i, j int) bool { return choices[i].Label < choices[j].Label }) + return choices, nil +} + +func setupBrokerTeamInfo(cfg config, teamID string) (brokerTeamInfo, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return brokerTeamInfo{}, err + } + var resp brokerTeamsResponse + if err := brokerPost(brokerURL, "/teams/list", brokerRepoAdminRequest{}, &resp); err != nil { + return brokerTeamInfo{}, err + } + for _, team := range resp.Teams { + if team.ID == teamID || strings.EqualFold(team.Name, teamID) { + return team, nil + } + } + return brokerTeamInfo{}, fmt.Errorf("team %s not found", teamID) +} + +func setupFormatTeamMembers(team brokerTeamInfo) string { + if len(team.Members) == 0 { + return "No members." + } + var lines []string + lines = append(lines, fmt.Sprintf("%-32s %-14s", "User", "Team role cap")) + lines = append(lines, fmt.Sprintf("%-32s %-14s", strings.Repeat("-", 4), strings.Repeat("-", 13))) + for _, member := range team.Members { + user := firstNonEmpty(member.Username, member.UserID) + if user == "" { + continue + } + lines = append(lines, fmt.Sprintf("%-32s %-14s", user, firstNonEmpty(member.Role, "read"))) + } + if len(lines) > 2 { + sort.Strings(lines[2:]) + } + return strings.Join(lines, "\n") +} + +func setupTeamDisplayName(fallback string, team brokerTeamInfo) string { + return firstNonEmpty(team.Name, team.ID, fallback) +} + +func setupBrokerUserChoices(cfg config) ([]setupChoice, error) { + users, err := setupBrokerUsers(cfg) + if err != nil { + return nil, err + } + return setupBrokerUserChoicesFromUsers(users, nil), nil +} + +func setupAvailableTeamUserChoices(cfg config, teamID string) ([]setupChoice, error) { + users, err := setupBrokerUsers(cfg) + if err != nil { + return nil, err + } + team, err := setupBrokerTeamInfo(cfg, teamID) + if err != nil { + return nil, err + } + members := map[string]struct{}{} + for _, member := range team.Members { + user := strings.ToLower(firstNonEmpty(member.Username, member.UserID)) + if user != "" { + members[user] = struct{}{} + } + } + return setupBrokerUserChoicesFromUsers(users, members), nil +} + +func setupAvailableRepoInviteUserChoices(cfg config, repo, teamID string) ([]setupChoice, error) { + users, err := setupBrokerUsers(cfg) + if err != nil { + return nil, err + } + exclude := map[string]struct{}{} + repoUsers, err := setupRepoUsers(cfg, repo, teamID) + if err != nil { + return nil, err + } + for _, user := range repoUsers { + if user.User != "" { + exclude[strings.ToLower(user.User)] = struct{}{} + } + } + brokerRepo, err := setupBrokerTeamRepoForAction(cfg, repo, teamID) + if err != nil { + return nil, err + } + pending, err := setupPendingRepoInviteChoices(cfg, brokerRepo) + if err != nil { + return nil, err + } + for _, invite := range pending { + if invite.Value != "" { + exclude[strings.ToLower(invite.Value)] = struct{}{} + } + } + return setupBrokerUserChoicesFromUsers(users, exclude), nil +} + +func setupBrokerUsers(cfg config) ([]brokerUserInfo, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return nil, err + } + var resp brokerUsersResponse + if err := brokerPost(brokerURL, "/broker/users/list", brokerRepoAdminRequest{}, &resp); err != nil { + return nil, err + } + return resp.Users, nil +} + +func setupBrokerUserByName(cfg config, username string) (brokerUserInfo, bool, error) { + users, err := setupBrokerUsers(cfg) + if err != nil { + return brokerUserInfo{}, false, err + } + needle := strings.ToLower(strings.TrimSpace(username)) + for _, user := range users { + if strings.ToLower(firstNonEmpty(user.Username, user.ID)) == needle { + return user, true, nil + } + } + return brokerUserInfo{}, false, nil +} + +func setupBrokerUserStatus(user brokerUserInfo) string { + parts := []string{firstNonEmpty(user.BrokerRole, "user")} + if user.Suspended { + parts = append(parts, "suspended") + } else if user.Pending || len(user.Keys) == 0 { + parts = append(parts, "pending") + } + if len(user.Keys) == 1 { + parts = append(parts, "1 key") + } else if len(user.Keys) > 1 { + parts = append(parts, fmt.Sprintf("%d keys", len(user.Keys))) + } + return strings.Join(parts, " · ") +} + +func setupBrokerUserChoicesFromUsers(users []brokerUserInfo, exclude map[string]struct{}) []setupChoice { + var choices []setupChoice + groupPending := exclude != nil + for _, user := range users { + username := firstNonEmpty(user.Username, user.ID) + if username == "" { + continue + } + if _, ok := exclude[strings.ToLower(username)]; ok { + continue + } + label := username + group := "" + if groupPending && (user.Pending || len(user.Keys) == 0) { + label += " *" + group = "pending users:" + label = "- " + label + } else if user.Pending || len(user.Keys) == 0 { + label += " *" + } + choices = append(choices, setupChoice{Label: label, Value: username, Help: user.BrokerRole, Group: group}) + } + sort.Slice(choices, func(i, j int) bool { + if choices[i].Group != choices[j].Group { + return choices[i].Group < choices[j].Group + } + return choices[i].Label < choices[j].Label + }) + return choices +} + +func setupBrokerTeamRepoChoices(cfg config, teamID string) ([]setupChoice, error) { + repos, err := setupBrokerRepos(cfg) + if err != nil { + return nil, err + } + var choices []setupChoice + for _, repo := range repos { + logical := firstNonEmpty(repo.Logical, repo.Repo.Logical) + for _, grant := range repo.Teams { + if grant.ID == teamID || grant.TeamID == teamID { + choices = append(choices, setupChoice{Label: logicalRepoDisplayName(logical), Value: logical, Help: "role cap " + firstNonEmpty(grant.Role, "read")}) + } + } + } + sort.Slice(choices, func(i, j int) bool { return choices[i].Label < choices[j].Label }) + return choices, nil +} + +func setupFormatTeamRepositories(cfg config, teamID string) string { + choices, err := setupBrokerTeamRepoChoices(cfg, teamID) + if err != nil { + return err.Error() + } + return setupFormatTeamRepositoriesForChoices(choices) +} + +func setupFormatTeamRepositoriesForChoices(choices []setupChoice) string { + if len(choices) == 0 { + return "No repository access." + } + var lines []string + lines = append(lines, fmt.Sprintf("%-32s %-14s", "Repository", "Role cap")) + lines = append(lines, fmt.Sprintf("%-32s %-14s", strings.Repeat("-", 10), strings.Repeat("-", 8))) + for _, choice := range choices { + role := strings.TrimPrefix(choice.Help, "role cap ") + lines = append(lines, fmt.Sprintf("%-32s %-14s", choice.Label, firstNonEmpty(role, "read"))) + } + return strings.Join(lines, "\n") +} + +func setupBrokerRepoChoices(cfg config) ([]setupChoice, error) { + repos, err := setupBrokerRepos(cfg) + if err != nil { + return nil, err + } + var choices []setupChoice + for _, repo := range repos { + logical := firstNonEmpty(repo.Logical, repo.Repo.Logical) + if logical == "" { + continue + } + choices = append(choices, setupChoice{Label: logicalRepoDisplayName(logical), Value: logical, Help: firstNonEmpty(repo.Repo.TeamID, coreTeamName)}) + } + sort.Slice(choices, func(i, j int) bool { return choices[i].Label < choices[j].Label }) + return choices, nil +} + +func setupBrokerRepos(cfg config) ([]brokerRepoInfo, error) { + brokerURL, err := brokerURLFromConfigOrDiscovery(cfg) + if err != nil { + return nil, err + } + var resp brokerRepoListResponse + if err := brokerPost(brokerURL, "/repos/list", brokerRepoAdminRequest{}, &resp); err != nil { + return nil, err + } + return resp.Repos, nil +} + +func runSetupSelectWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string, choices []setupChoice, selected string) (string, bool, error) { + if len(choices) == 0 { + return "", false, fmt.Errorf("%s has no selectable entries", strings.ToLower(title)) + } + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return "", false, err + } + defer restore() + state := setupSelectState{Title: title, Choices: choices, Button: -1} + for i, choice := range choices { + if choice.Value == selected || choice.Label == selected { + state.Cursor = i + break + } + } + for { + fmt.Fprint(stdout, renderSetupSelectFrame(state, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return "", false, nil + } + return "", false, err + } + switch b { + case 0x03: + return "", false, errors.New("setup canceled") + case 0x04: + value, ok := state.activate() + if ok { + return value, value != "", nil + } + case '\r', '\n', ' ': + value, ok := state.activate() + if ok { + return value, value != "", nil + } + case '\t': + state.tab() + case 0x1b: + last, ok, err := setupReadEscapeSequence(reader) + if err != nil { + return "", false, err + } + if !ok { + return "", false, nil + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + value, ok := state.activate() + if ok { + return value, value != "", nil + } + case 'D': + return "", false, nil + } + } + } +} + +func setupReadEscapeSequence(reader *bufio.Reader) (byte, bool, error) { + if reader.Buffered() == 0 { + return 0, false, nil + } + next, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return 0, false, nil + } + return 0, false, err + } + if next != '[' { + return next, true, nil + } + if reader.Buffered() == 0 { + return 0, false, nil + } + last, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return 0, false, nil + } + return 0, false, err + } + return last, true, nil +} + +func (s *setupSelectState) rows() int { return len(s.Choices) } + +func (s *setupSelectState) visibleRange() (int, int) { + const maxRows = 10 + rows := s.rows() + if s.Cursor < s.Scroll { + s.Scroll = s.Cursor + } + if s.Cursor >= s.Scroll+maxRows { + s.Scroll = s.Cursor - maxRows + 1 + } + if s.Scroll < 0 { + s.Scroll = 0 + } + if s.Scroll > rows-maxRows { + s.Scroll = maxSetupDialogInt(0, rows-maxRows) + } + return s.Scroll, minSetupDialogInt(s.Scroll+maxRows, rows) +} + +func (s *setupSelectState) up() { + if s.rows() == 0 { + return + } + s.Button = -1 + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 + return + } + s.Cursor-- +} + +func (s *setupSelectState) down() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Cursor = (s.Cursor + 1) % s.rows() +} + +func (s *setupSelectState) tab() { + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 + return + } + if s.Button < 0 { + s.Button = 0 + return + } + s.Button = (s.Button + 1) % 2 +} + +func (s setupSelectState) activate() (string, bool) { + if s.Button == 1 { + return "", true + } + if s.Button == 0 || s.Button < 0 { + if s.Cursor >= 0 && s.Cursor < len(s.Choices) { + return s.Choices[s.Cursor].Value, true + } + } + return "", false +} + +func renderSetupSelectFrame(state setupSelectState, rawMode bool) string { + rendered := renderSetupSelectWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupSelectWithStyle(state setupSelectState, style bool) string { + start, end := state.visibleRange() + width := setupDialogDynamicWidth(58, state.Title, "Up/Down move Right/Enter select Tab buttons", "Left/Esc back Ctrl-C cancel") + for i := start; i < end; i++ { + choice := state.Choices[i] + width = setupDialogDynamicWidth(width, fmt.Sprintf("> %-26s %s", choice.Label, choice.Help)) + if choice.Group != "" { + width = setupDialogDynamicWidth(width, choice.Group) + } + } + var lines []string + lines = append(lines, + setupDialogBorder(width), + setupDialogTitleRow(width), + setupDialogBorder(width), + setupDialogRowWidth(state.Title, width), + setupDialogRowWidth("", width), + ) + lastGroup := "" + for i := start; i < end; i++ { + choice := state.Choices[i] + if choice.Group != "" && choice.Group != lastGroup { + lines = append(lines, setupDialogRowWidth(choice.Group, width)) + lastGroup = choice.Group + } + marker := " " + if state.Button < 0 && state.Cursor == i { + marker = ">" + } + lines = append(lines, setupDialogRowStyledWidth(fmt.Sprintf("%s %-26s %s", marker, choice.Label, choice.Help), width, setupDialogSectionStyle(style, state.Button < 0 && state.Cursor == i))) + } + if len(state.Choices) > 10 { + lines = append(lines, setupDialogRowWidth(setupBrokerScrollBar(start, end, len(state.Choices)), width)) + } + okStyle := "" + cancelStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + setupDialogRowWidth("", width), + setupDialogBorder(width), + setupDialogRowWidth(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle), width), + setupDialogRowWidth("Up/Down move Right/Enter select Tab buttons", width), + setupDialogRowWidth("Left/Esc back Ctrl-C cancel", width), + setupDialogBorder(width), + ) + rendered := strings.Join(lines, "\n") + "\n" + if setupSelectHasPendingUserNote(state) { + rendered += "* pending invite or no accepted key yet\n" + } + return rendered +} + +func setupSelectHasPendingUserNote(state setupSelectState) bool { + if !strings.Contains(strings.ToLower(state.Title), "username") { + return false + } + start, end := state.visibleRange() + for i := start; i < end; i++ { + if strings.HasSuffix(strings.TrimSpace(state.Choices[i].Label), "*") { + return true + } + } + return false +} + +func runSetupMultiSelectWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string, choices []setupChoice) ([]string, bool, error) { + if len(choices) == 0 { + return nil, false, fmt.Errorf("%s has no selectable entries", strings.ToLower(title)) + } + state := setupMultiSelectState{Title: title, Choices: choices, Selected: make([]bool, len(choices)), Button: -1} + for { + fmt.Fprint(stdout, renderSetupMultiSelectFrame(state, true)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, false, nil + } + return nil, false, err + } + switch b { + case 0x03: + return nil, false, errors.New("setup canceled") + case 0x04: + if selected, ok := state.deploy(); ok { + return selected, true, nil + } + case '\r', '\n', ' ': + if state.Button == 1 { + return nil, false, nil + } + if state.Button == 0 { + if selected, ok := state.deploy(); ok { + return selected, true, nil + } + continue + } + state.toggle() + case '\t': + state.tab() + case 0x1b: + last, ok, err := setupReadEscapeSequence(reader) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, nil + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + if selected, ok := state.deploy(); ok { + return selected, true, nil + } + case 'D': + return nil, false, nil + } + } + } +} + +func (s *setupMultiSelectState) rows() int { return len(s.Choices) } + +func (s *setupMultiSelectState) visibleRange() (int, int) { + const maxRows = 10 + rows := s.rows() + if s.Cursor < s.Scroll { + s.Scroll = s.Cursor + } + if s.Cursor >= s.Scroll+maxRows { + s.Scroll = s.Cursor - maxRows + 1 + } + if s.Scroll < 0 { + s.Scroll = 0 + } + if s.Scroll > rows-maxRows { + s.Scroll = maxSetupDialogInt(0, rows-maxRows) + } + return s.Scroll, minSetupDialogInt(s.Scroll+maxRows, rows) +} + +func (s *setupMultiSelectState) up() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 + return + } + s.Cursor-- +} + +func (s *setupMultiSelectState) down() { + if s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + s.Cursor = (s.Cursor + 1) % s.rows() +} + +func (s *setupMultiSelectState) tab() { + s.Message = "" + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 + return + } + if s.Button < 0 { + s.Button = 0 + return + } + s.Button = (s.Button + 1) % 2 +} + +func (s *setupMultiSelectState) toggle() { + s.Message = "" + if s.Cursor >= 0 && s.Cursor < len(s.Selected) { + s.Selected[s.Cursor] = !s.Selected[s.Cursor] + } +} + +func (s *setupMultiSelectState) deploy() ([]string, bool) { + var selected []string + for i, ok := range s.Selected { + if ok { + selected = append(selected, s.Choices[i].Value) + } + } + if len(selected) == 0 { + s.Message = "Select at least one repository." + return nil, false + } + return selected, true +} + +func renderSetupMultiSelectFrame(state setupMultiSelectState, rawMode bool) string { + rendered := renderSetupMultiSelectWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupMultiSelectWithStyle(state setupMultiSelectState, style bool) string { + start, end := state.visibleRange() + width := setupDialogDynamicWidth(58, state.Title, "Space/Enter toggles Right/Ctrl-D OK", "Left/Esc back Arrows move Tab buttons") + for i := start; i < end; i++ { + choice := state.Choices[i] + width = setupDialogDynamicWidth(width, fmt.Sprintf("> [x] %-22s %s", choice.Label, choice.Help)) + } + var lines []string + lines = append(lines, + setupDialogBorder(width), + setupDialogTitleRow(width), + setupDialogBorder(width), + setupDialogRowWidth(state.Title, width), + setupDialogRowWidth("", width), + ) + for i := start; i < end; i++ { + choice := state.Choices[i] + marker := " " + if state.Button < 0 && state.Cursor == i { + marker = ">" + } + checked := "[ ]" + if i < len(state.Selected) && state.Selected[i] { + checked = "[x]" + } + lines = append(lines, setupDialogRowStyledWidth(fmt.Sprintf("%s %s %-22s %s", marker, checked, choice.Label, choice.Help), width, setupDialogSectionStyle(style, state.Button < 0 && state.Cursor == i))) + } + if len(state.Choices) > 10 { + lines = append(lines, setupDialogRowWidth(setupBrokerScrollBar(start, end, len(state.Choices)), width)) + } + if state.Message != "" { + lines = append(lines, setupDialogRowStyledWidth(state.Message, width, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + setupDialogRowWidth("", width), + setupDialogBorder(width), + setupDialogRowWidth(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle), width), + setupDialogRowWidth("Space/Enter toggles Right/Ctrl-D OK", width), + setupDialogRowWidth("Left/Esc back Arrows move Tab buttons", width), + setupDialogBorder(width), + ) + return strings.Join(lines, "\n") + "\n" +} + +func runSetupTextFormWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title string, fields []setupTextField) ([]string, bool, error) { + state := setupTextFormState{Title: title, Fields: fields, Button: -1} + for { + fmt.Fprint(stdout, renderSetupTextFormFrame(state, true)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, false, nil + } + return nil, false, err + } + switch b { + case 0x03: + return nil, false, errors.New("setup canceled") + case 0x04: + if values, ok := state.deploy(); ok { + return values, true, nil + } + case '\r', '\n': + if state.Editing { + state.Editing = false + state.EditOriginal = "" + continue + } + if state.Button == 1 { + return nil, false, nil + } + if values, ok := state.activate(); ok { + return values, true, nil + } + case ' ': + if state.Editing { + state.appendByte(b) + continue + } + if state.Button == 1 { + return nil, false, nil + } + if values, ok := state.activate(); ok { + return values, true, nil + } + case '\t': + state.tab() + case 0x7f, 0x08: + if state.Editing { + state.backspace() + } + case 0x1b: + if state.Editing { + state.Fields[state.Cursor].Value = state.EditOriginal + state.Editing = false + state.EditOriginal = "" + continue + } + last, ok, err := setupReadEscapeSequence(reader) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, nil + } + switch last { + case 'A': + state.up() + case 'B': + state.down() + case 'C': + if values, ok := state.activate(); ok { + return values, true, nil + } + case 'D': + return nil, false, nil + } + default: + if state.Editing { + state.appendByte(b) + } + } + } +} + +func (s *setupTextFormState) rows() int { return len(s.Fields) } + +func (s *setupTextFormState) up() { + if s.Editing || s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + if s.Cursor == 0 { + s.Cursor = s.rows() - 1 + return + } + s.Cursor-- +} + +func (s *setupTextFormState) down() { + if s.Editing || s.rows() == 0 { + return + } + s.Button = -1 + s.Message = "" + s.Cursor = (s.Cursor + 1) % s.rows() +} + +func (s *setupTextFormState) tab() { + if s.Editing { + s.Editing = false + s.EditOriginal = "" + } + s.Message = "" + if s.Button == 1 { + s.Button = -1 + s.Cursor = 0 + return + } + if s.Button < 0 { + s.Button = 0 + return + } + s.Button = (s.Button + 1) % 2 +} + +func (s *setupTextFormState) activate() ([]string, bool) { + if s.Button == 0 { + return s.deploy() + } + if s.Button == 1 { + return nil, false + } + if s.Cursor >= 0 && s.Cursor < len(s.Fields) { + s.Editing = true + s.EditOriginal = s.Fields[s.Cursor].Value + s.Message = "" + } + return nil, false +} + +func (s *setupTextFormState) deploy() ([]string, bool) { + var values []string + for _, field := range s.Fields { + value := strings.TrimSpace(field.Value) + if field.Required && value == "" { + s.Message = field.Label + " is required." + return nil, false + } + values = append(values, value) + } + return values, true +} + +func (s *setupTextFormState) appendByte(b byte) { + if b == '\r' || b == '\n' { + s.Editing = false + s.EditOriginal = "" + return + } + if b < 32 || b > 126 { + return + } + if s.Cursor < 0 || s.Cursor >= len(s.Fields) || len(s.Fields[s.Cursor].Value) >= 160 { + return + } + s.Fields[s.Cursor].Value += string(b) +} + +func (s *setupTextFormState) backspace() { + if s.Cursor < 0 || s.Cursor >= len(s.Fields) { + return + } + value := s.Fields[s.Cursor].Value + if len(value) == 0 { + return + } + s.Fields[s.Cursor].Value = value[:len(value)-1] +} + +func renderSetupTextFormFrame(state setupTextFormState, rawMode bool) string { + rendered := renderSetupTextFormWithStyle(state, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupTextFormWithStyle(state setupTextFormState, style bool) string { + width := setupDialogDynamicWidth(58, state.Title, "Enter edits/saves field Tab fields/buttons", "Ctrl-D OK Esc cancel/revert Ctrl-C cancel") + for _, field := range state.Fields { + width = setupDialogDynamicWidth(width, fmt.Sprintf("> %-18s [%s]", field.Label, initDialogInputValue(field.Value, 31, false, false))) + } + inputWidth := maxSetupDialogInt(31, width-24) + var lines []string + lines = append(lines, + setupDialogBorder(width), + setupDialogTitleRow(width), + setupDialogBorder(width), + setupDialogRowWidth(state.Title, width), + setupDialogRowWidth("", width), + ) + for i, field := range state.Fields { + active := state.Button < 0 && state.Cursor == i + marker := " " + if active { + marker = ">" + } + inputStyle := setupDialogSectionStyle(style, active) + if style && state.Editing && active { + inputStyle += "\x1b[44;97m" + } + value := field.Value + if field.Secret { + value = strings.Repeat("*", len(value)) + } + lines = append(lines, setupDialogRowStyledWidth(fmt.Sprintf("%s %-18s [%s]", marker, field.Label, initDialogInputValue(value, inputWidth, state.Editing && active, style)), width, inputStyle)) + } + if state.Message != "" { + lines = append(lines, setupDialogRowStyledWidth(state.Message, width, setupDialogANSI(style, "33"))) + } + okStyle := "" + cancelStyle := "" + if style && state.Button == 0 { + okStyle = "\x1b[44;97m" + } + if style && state.Button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines = append(lines, + setupDialogRowWidth("", width), + setupDialogBorder(width), + setupDialogRowWidth(setupDialogButton("[ OK ]", okStyle)+" "+setupDialogButton("[ Cancel ]", cancelStyle), width), + setupDialogRowWidth("Enter edits/saves field Tab fields/buttons", width), + setupDialogRowWidth("Ctrl-D OK Esc cancel/revert Ctrl-C cancel", width), + setupDialogBorder(width), + ) + return strings.Join(lines, "\n") + "\n" +} + +func runSetupBrokerOutputWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, title, body string) (string, error) { + for { + fmt.Fprint(stdout, renderSetupBrokerOutputFrame(title, body, true)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return "Done.", nil + } + return "", err + } + switch b { + case 0x03: + return "", errors.New("setup canceled") + case '\r', '\n', ' ', 0x04, 0x1b, 'q', 'Q': + return "Done.", nil + } + } +} + +func runSetupPlainCommandOutputWithRaw(reader *bufio.Reader, stdout io.Writer, title, command string) (string, error) { + command = strings.TrimSpace(command) + if command == "" { + command = "No command was returned." + } + rendered := strings.TrimSpace(title) + "\n\n" + command + "\n\nPress any key to continue\n" + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + fmt.Fprint(stdout, "\x1b[?25h\x1b[H\x1b[2J"+rendered) + b, err := reader.ReadByte() + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + if b == 0x03 { + return "", errors.New("setup canceled") + } + return "Done.", nil +} + +func setupAcceptCommandFromOutput(output string) string { + lines := strings.Split(output, "\n") + for i, line := range lines { + if strings.Contains(strings.ToLower(line), "give this command") { + for _, candidate := range lines[i+1:] { + candidate = strings.TrimSpace(candidate) + if strings.HasPrefix(candidate, "bgit ") { + return candidate + } + } + } + } + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "bgit ") { + return line + } + } + return strings.TrimSpace(output) +} + +func renderSetupBrokerOutputFrame(title, body string, rawMode bool) string { + rendered := renderSetupBrokerOutputWithStyle(title, body, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupBrokerOutputWithStyle(title, body string, style bool) string { + width := setupDialogDynamicWidth(72, title, "Enter/Esc returns to previous menu") + body = strings.TrimSpace(body) + if body == "" { + body = "No entries." + } + for _, line := range strings.Split(body, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + width = setupDialogDynamicWidth(width, line) + } + var lines []string + lines = append(lines, + setupDialogBorder(width), + setupDialogTitleRow(width), + setupDialogBorder(width), + setupDialogRowWidth(title, width), + setupDialogRowWidth("", width), + ) + bodyLines := setupWrapOutputLines(body, width) + if len(bodyLines) > 18 { + bodyLines = bodyLines[:12] + bodyLines = append(bodyLines, "...") + } + for _, line := range bodyLines { + lines = append(lines, setupDialogRowWidth(line, width)) + } + lines = append(lines, + setupDialogRowWidth("", width), + setupDialogBorder(width), + setupDialogRowWidth("[ OK ]", width), + setupDialogRowWidth("Enter/Esc returns to previous menu", width), + setupDialogBorder(width), + ) + return strings.Join(lines, "\n") + "\n" +} + +func setupWrapOutputLines(body string, width int) []string { + var out []string + for _, line := range strings.Split(body, "\n") { + if len(stripSetupANSI(line)) <= width { + out = append(out, line) + continue + } + out = append(out, setupWrapHard(line, width)...) + } + return out +} + +func setupWrapHard(line string, width int) []string { + if width <= 0 { + return []string{line} + } + var out []string + prefix := "" + if strings.HasPrefix(line, " ") { + prefix = " " + } + remaining := line + for len(stripSetupANSI(remaining)) > width { + cut := width + if prefix != "" && len(out) > 0 { + cut = width - len(prefix) + } + if cut < 8 { + cut = width + } + part := remaining[:minSetupDialogInt(cut, len(remaining))] + if len(out) > 0 && prefix != "" { + part = prefix + part + } + out = append(out, part) + remaining = remaining[minSetupDialogInt(cut, len(remaining)):] + } + if remaining != "" { + if len(out) > 0 && prefix != "" { + remaining = prefix + remaining + } + out = append(out, remaining) + } + return out +} + +func runSetupBrokerDeleteConfirmWithRaw(reader *bufio.Reader, rawInput io.Reader, stdout io.Writer, broker setupConfiguredBroker) (bool, error) { + rawMode, restore, err := setupDialogRawMode(rawInput) + if err != nil { + return false, err + } + defer restore() + button := 1 + for { + fmt.Fprint(stdout, renderSetupBrokerDeleteConfirmFrame(broker, button, rawMode)) + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + return false, nil + } + return false, err + } + switch b { + case 0x03: + return false, errors.New("setup canceled") + case '\t': + button = (button + 1) % 2 + case '\r', '\n', ' ', 0x04: + return button == 0, nil + case 0x1b, 'q', 'Q': + return false, nil + } + } +} + +func renderSetupBrokerDeleteConfirmFrame(broker setupConfiguredBroker, button int, rawMode bool) string { + rendered := renderSetupBrokerDeleteConfirmWithStyle(broker, button, rawMode) + if !rawMode { + return rendered + } + rendered = strings.ReplaceAll(rendered, "\n", "\r\n") + return "\x1b[?25l\x1b[H\x1b[2J" + rendered +} + +func renderSetupBrokerDeleteConfirmWithStyle(broker setupConfiguredBroker, button int, style bool) string { + deleteStyle := "" + cancelStyle := "" + if style && button == 0 { + deleteStyle = "\x1b[41;97m" + } + if style && button == 1 { + cancelStyle = "\x1b[44;97m" + } + lines := []string{ + "+------------------------------------------------------------+", + "| BUCKETGIT SETUP |", + "+------------------------------------------------------------+", + setupDialogRow("Delete broker " + setupBrokerQualifiedName(broker) + "?"), + setupDialogRow("This removes broker infrastructure for this region."), + setupDialogRow("Repository buckets are not deleted by broker delete."), + "| |", + "+------------------------------------------------------------+", + setupDialogRow(setupDialogButton("[ Delete ]", deleteStyle) + " " + setupDialogButton("[ Cancel ]", cancelStyle)), + setupDialogRow("Tab buttons Enter select Esc cancel"), + "+------------------------------------------------------------+", + } + return strings.Join(lines, "\n") + "\n" +} + +func setupAvailableCreateProviders() []string { + var providers []string + if _, err := exec.LookPath("gcloud"); err == nil { + providers = append(providers, "gcs") + } + if _, err := exec.LookPath("aws"); err == nil { + providers = append(providers, "s3") + } + return providers +} + +func (s setupDialogState) selection() setupSelection { + var profiles []setupProfile + for i, profile := range s.profiles { + if i < len(s.selectedProfiles) && s.selectedProfiles[i] { + profiles = append(profiles, profile) + } + } + var keys []setupSSHKey + for i, key := range s.keys { + if i < len(s.selectedKeys) && s.selectedKeys[i] { + keys = append(keys, key) + } + } + return setupSelection{ + Profiles: profiles, + Keys: keys, + IdentityName: strings.TrimSpace(s.identityName), + IdentityEmail: strings.TrimSpace(s.identityEmail), + } +} + +func (s *setupDialogState) rows() int { + if s.createProvider != "" { + return len(s.createFields()) + } + return len(s.visibleItems()) +} + +func (s *setupDialogState) up() { + if s.editingCreate || s.editingIdentity { + return + } + if s.rows() == 0 { + return + } + s.button = -1 + s.message = "" + if s.cursor == 0 { + s.cursor = s.rows() - 1 + return + } + s.cursor-- +} + +func (s *setupDialogState) down() { + if s.editingCreate || s.editingIdentity { + return + } + if s.rows() == 0 { + return + } + s.button = -1 + s.message = "" + s.cursor = (s.cursor + 1) % s.rows() +} + +func (s *setupDialogState) activate() (setupSelection, bool) { + if s.createProvider != "" { + if s.button == 0 { + return s.deployCreateProfile() + } + if s.button == 1 { + s.cancelCreateProfile() + return setupSelection{}, false + } + s.editingCreate = true + s.editOriginal = s.createFieldValue() + s.message = "" + return setupSelection{}, false + } + if s.button == 0 { + return s.deploy() + } + if s.button == 1 { + return setupSelection{}, false + } + items := s.visibleItems() + if s.cursor < 0 || s.cursor >= len(items) { + return setupSelection{}, false + } + s.message = "" + item := items[s.cursor] + switch item.Kind { + case "identity-name", "identity-email": + s.editingIdentity = true + s.editOriginal = s.identityFieldValue() + s.message = "" + case "create-profile": + s.createProvider = item.Provider + s.createName = firstNonEmpty(s.defaultCreateByProvider[item.Provider], s.defaultCreate) s.editingCreate = true s.editOriginal = s.createFieldValue() s.cursor = 0 @@ -2685,6 +5892,13 @@ func minSetupDialogInt(a, b int) int { return b } +func maxSetupDialogInt(a, b int) int { + if a > b { + return a + } + return b +} + func renderSetupDialog(state setupDialogState) string { return renderSetupDialogWithStyle(state, false) } @@ -2971,6 +6185,42 @@ func setupDialogProviderVisibleItems(state setupDialogState, provider string) [] return items } +func setupBreadcrumb(parts ...string) string { + var cleaned []string + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + cleaned = append(cleaned, part) + } + } + return strings.Join(cleaned, " > ") +} + +func setupDialogDynamicWidth(base int, values ...string) int { + width := base + for _, value := range values { + if n := len(stripSetupANSI(value)); n > width { + width = n + } + } + if width < 58 { + width = 58 + } + if width > 100 { + width = 100 + } + return width +} + +func setupDialogTitleRow(width int) string { + title := "BUCKETGIT SETUP" + if len(title) >= width { + return setupDialogRowWidth(title, width) + } + left := (width - len(title)) / 2 + return setupDialogRowWidth(strings.Repeat(" ", left)+title, width) +} + func setupDialogRow(text string) string { visible := stripSetupANSI(text) if len(visible) > 58 { @@ -2980,6 +6230,19 @@ func setupDialogRow(text string) string { return "| " + text + strings.Repeat(" ", 58-len(visible)) + " |" } +func setupDialogBorder(width int) string { + return "+" + strings.Repeat("-", width+2) + "+" +} + +func setupDialogRowWidth(text string, width int) string { + visible := stripSetupANSI(text) + if len(visible) > width { + text = visible[:width] + visible = text + } + return "| " + text + strings.Repeat(" ", width-len(visible)) + " |" +} + func setupDialogRowStyled(text, style string) string { visible := text if len(visible) > 58 { @@ -2992,6 +6255,60 @@ func setupDialogRowStyled(text, style string) string { return "| " + text + strings.Repeat(" ", 58-len(visible)) + " |" } +func setupDialogRowStyledWidth(text string, width int, style string) string { + visible := stripSetupANSI(text) + if len(visible) > width { + text = visible[:width] + visible = text + } + if style != "" { + text = style + text + "\x1b[0m" + } + return "| " + text + strings.Repeat(" ", width-len(visible)) + " |" +} + +func setupDialogWrappedActionRows(marker, label, help string, labelWidth, width int, style string) []string { + prefixWidth := 2 + labelWidth + 1 + helpWidth := width - prefixWidth + if helpWidth < 12 { + helpWidth = 12 + } + parts := setupWrapWords(help, helpWidth) + if len(parts) == 0 { + parts = []string{""} + } + rows := []string{setupDialogRowStyledWidth(fmt.Sprintf("%s %-*s %s", marker, labelWidth, label, parts[0]), width, style)} + for _, part := range parts[1:] { + rows = append(rows, setupDialogRowStyledWidth(fmt.Sprintf(" %-*s %s", labelWidth, "", part), width, style)) + } + return rows +} + +func setupWrapWords(text string, width int) []string { + words := strings.Fields(text) + if len(words) == 0 { + return nil + } + var lines []string + line := "" + for _, word := range words { + if line == "" { + line = word + continue + } + if len(line)+1+len(word) > width { + lines = append(lines, line) + line = word + continue + } + line += " " + word + } + if line != "" { + lines = append(lines, line) + } + return lines +} + func setupProviderLabel(provider string) string { if provider == "s3" { return "aws" @@ -3022,6 +6339,14 @@ func brokerUpsertOwners(brokerURL string, publicKeys []string) error { return brokerPost(brokerURL, "/owners/upsert", brokerOwnerRequest{User: "owner", Role: "owner", PublicKeys: publicKeys}, nil) } +func brokerEnsureCoreTeam(brokerURL string) error { + err := brokerPost(brokerURL, "/teams/create", brokerRepoAdminRequest{TeamID: coreTeamID, Name: coreTeamName}, nil) + if err != nil && !strings.Contains(err.Error(), "team already exists") { + return err + } + return nil +} + func upsertGlobalGCPProfile(cfg globalConfig, profile globalGCPProfile) globalConfig { for i, existing := range cfg.GCPProfiles { if existing.Name == profile.Name { diff --git a/setup_test.go b/setup_test.go index 85c3ab2..696e4cb 100644 --- a/setup_test.go +++ b/setup_test.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/json" @@ -158,6 +159,210 @@ func TestConfiguredSetupProfilesExpandConfiguredRegions(t *testing.T) { } } +func TestConfiguredSetupBrokersSortedAndDetected(t *testing.T) { + cfg := globalConfig{ + GCPProfiles: []globalGCPProfile{{ + Name: "work", + ProjectID: "project-123", + Regions: []globalProfileRegion{{ + Name: "europe-west1", + BrokerURL: "https://gcp.example.test", + }}, + }}, + AWSProfiles: []globalAWSProfile{{ + Name: "prod", + AccountID: "123456789012", + Regions: []globalProfileRegion{{ + Name: "us-east-1", + BrokerURL: "https://aws.example.test", + }}, + }}, + } + got := configuredSetupBrokers(cfg) + if len(got) != 2 { + t.Fatalf("brokers = %#v", got) + } + if got[0].Provider != "s3" || got[0].Profile != "prod" || got[1].Provider != "gcs" || got[1].Profile != "work" { + t.Fatalf("unexpected broker order = %#v", got) + } + if !configuredSetupBrokerExists(cfg, "gcp", "work", "europe-west1") { + t.Fatalf("configured broker not detected") + } + if configuredSetupBrokerExists(cfg, "gcp", "work", "us-central1") { + t.Fatalf("unconfigured region detected") + } +} + +func TestSetupBrokerHomeSelectsExistingBroker(t *testing.T) { + var stdout bytes.Buffer + action, broker, err := runSetupBrokerHomeWithRaw(bufio.NewReader(strings.NewReader(" ")), strings.NewReader(" "), &stdout, []setupConfiguredBroker{{ + Provider: "gcs", + Profile: "work", + Region: "europe-west1", + BrokerURL: "https://broker.example.test", + Detail: "project-123", + }}, []string{"gcs"}) + if err != nil { + t.Fatal(err) + } + if action != "broker" || broker.Profile != "work" || broker.Region != "europe-west1" { + t.Fatalf("action=%q broker=%#v", action, broker) + } + if !strings.Contains(stdout.String(), "Broker setups") || !strings.Contains(stdout.String(), "gcp:work.europe-west1") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupBrokerActionSelectsUpdate(t *testing.T) { + var stdout bytes.Buffer + action, err := runSetupBrokerActionWithRaw(bufio.NewReader(strings.NewReader("\x1b[B ")), strings.NewReader("\x1b[B "), &stdout, setupConfiguredBroker{ + Provider: "s3", + Profile: "prod", + Region: "us-east-1", + BrokerURL: "https://broker.example.test", + }) + if err != nil { + t.Fatal(err) + } + if action != "update" { + t.Fatalf("action = %q", action) + } + if !strings.Contains(stdout.String(), "update broker") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupBrokerActionLeftReturnsToBrokerList(t *testing.T) { + var stdout bytes.Buffer + action, err := runSetupBrokerActionWithRaw(bufio.NewReader(strings.NewReader("\x1b[D")), strings.NewReader("\x1b[D"), &stdout, setupConfiguredBroker{ + Provider: "gcs", + Profile: "work", + Region: "us-central1", + BrokerURL: "https://broker.example.test", + }) + if err != nil { + t.Fatal(err) + } + if action != "back" { + t.Fatalf("action = %q", action) + } +} + +func TestSetupSelectRoleChoosesFromDropdown(t *testing.T) { + var stdout bytes.Buffer + got, ok, err := runSetupSelectWithRaw(bufio.NewReader(strings.NewReader("\x1b[B ")), strings.NewReader("\x1b[B "), &stdout, "Role", setupBrokerUserRoleChoices(), "user") + if err != nil { + t.Fatal(err) + } + if !ok || got != "admin" { + t.Fatalf("ok=%v role=%q", ok, got) + } + if !strings.Contains(stdout.String(), "Role") || !strings.Contains(stdout.String(), "admin") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupBrokerManageLabelsAreUserFacing(t *testing.T) { + rendered := renderSetupBrokerManageWithStyle(setupBrokerManageState{Broker: setupConfiguredBroker{ + Provider: "gcs", + Profile: "work", + Region: "us-central1", + BrokerURL: "https://broker.example.test", + }}, false) + for _, want := range []string{"manage broker users", "team management"} { + if !strings.Contains(rendered, want) { + t.Fatalf("manage dialog missing %q:\n%s", want, rendered) + } + } + for _, reject := range []string{"upsert broker user", "list broker users", "create user invite", "grant team repo", "remove team repo"} { + if strings.Contains(rendered, reject) { + t.Fatalf("manage dialog contains technical label %q:\n%s", reject, rendered) + } + } + for _, reject := range []string{"transfer owner", "cancel owner transfer"} { + if strings.Contains(rendered, reject) { + t.Fatalf("owner transfer should live under broker users, found %q:\n%s", reject, rendered) + } + } +} + +func TestSetupManagedTeamEscapeReturnsToPreviousMenu(t *testing.T) { + var stdout bytes.Buffer + msg, err := runSetupManagedTeamWithRaw(config{}, "core", "core", bufio.NewReader(strings.NewReader("\x1b")), strings.NewReader("\x1b"), &stdout) + if !errors.Is(err, errSetupBack) { + t.Fatalf("err = %v, want errSetupBack", err) + } + if msg != "" { + t.Fatalf("msg = %q", msg) + } + if !strings.Contains(stdout.String(), "Manage team") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupFormatsTeamTablesWithHeaders(t *testing.T) { + members := setupFormatTeamMembers(brokerTeamInfo{ + Name: "core", + Members: []brokerTeamMember{{ + Username: "owner", + Role: "admin", + }}, + }) + if !strings.Contains(members, "User") || !strings.Contains(members, "Team role cap") || strings.Contains(members, "\tmax") { + t.Fatalf("members table = %q", members) + } + repos := setupFormatTeamRepositoriesForChoices([]setupChoice{{ + Label: "demo", + Help: "role cap developer", + }}) + if !strings.Contains(repos, "Repository") || !strings.Contains(repos, "Role cap") || strings.Contains(repos, "\tcap") { + t.Fatalf("repos table = %q", repos) + } +} + +func TestSetupSelectLeftAndRightArrows(t *testing.T) { + var stdout bytes.Buffer + got, ok, err := runSetupSelectWithRaw(bufio.NewReader(strings.NewReader("\x1b[C")), strings.NewReader("\x1b[C"), &stdout, "Role", setupBrokerUserRoleChoices(), "user") + if err != nil { + t.Fatal(err) + } + if !ok || got != "user" { + t.Fatalf("right arrow ok=%v got=%q", ok, got) + } + stdout.Reset() + got, ok, err = runSetupSelectWithRaw(bufio.NewReader(strings.NewReader("\x1b[D")), strings.NewReader("\x1b[D"), &stdout, "Role", setupBrokerUserRoleChoices(), "user") + if err != nil { + t.Fatal(err) + } + if ok || got != "" { + t.Fatalf("left arrow ok=%v got=%q", ok, got) + } +} + +func TestSetupPendingUserNoteOnlyWhenPendingAndOutsideFrame(t *testing.T) { + withoutPending := renderSetupSelectWithStyle(setupSelectState{ + Title: "Username", + Choices: []setupChoice{ + {Label: "alice", Value: "alice"}, + }, + }, false) + if strings.Contains(withoutPending, "pending invite") { + t.Fatalf("unexpected pending note:\n%s", withoutPending) + } + withPending := renderSetupSelectWithStyle(setupSelectState{ + Title: "Username", + Choices: []setupChoice{ + {Label: "alice *", Value: "alice"}, + }, + }, false) + if !strings.Contains(withPending, "\n* pending invite or no accepted key yet\n") { + t.Fatalf("missing pending note below dialog:\n%s", withPending) + } + if strings.Contains(withPending, "| * pending invite or no accepted key yet") { + t.Fatalf("pending note rendered inside dialog:\n%s", withPending) + } +} + func TestSetupDialogRendersCheckboxesAndKeys(t *testing.T) { rendered := renderSetupDialog(setupDialogState{ profiles: []setupProfile{{ @@ -432,12 +637,14 @@ func TestSetupCommandProvisionsGCPAndWritesGlobalConfig(t *testing.T) { } var ownerReq brokerOwnerRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owners/upsert" { + switch r.URL.Path { + case "/owners/upsert": + if err := json.NewDecoder(r.Body).Decode(&ownerReq); err != nil { + t.Fatal(err) + } + default: t.Fatalf("unexpected broker path %s", r.URL.Path) } - if err := json.NewDecoder(r.Body).Decode(&ownerReq); err != nil { - t.Fatal(err) - } _, _ = w.Write([]byte(`{"ok":true}`)) })) defer server.Close() @@ -579,8 +786,8 @@ func TestEnsureGcloudSetupProjectAccessRunsLoginOnUserProjectDenied(t *testing.T bin := t.TempDir() authMarker := filepath.Join(t.TempDir(), "authed") writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ - {match: "config get-value project", stdout: "hurozo"}, - {match: "projects describe hurozo", stdout: "hurozo", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission", requireFile: authMarker, exitCode: 1}, + {match: "config get-value project", stdout: "example-project"}, + {match: "projects describe example-project", stdout: "example-project", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission", requireFile: authMarker, exitCode: 1}, {match: "auth login --configuration default --no-launch-browser", stdout: "https://example.test/oauth", touch: authMarker}, }) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -599,10 +806,10 @@ func TestEnsureGcloudSetupProjectAccessRepairsQuotaProject(t *testing.T) { bin := t.TempDir() quotaMarker := filepath.Join(t.TempDir(), "quota") writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ - {match: "config get-value project", stdout: "hurozo"}, - {match: "projects describe hurozo", stdout: "hurozo", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission to use project aafje-490407", requireFile: quotaMarker, exitCode: 1}, - {match: "config get-value billing/quota_project", stdout: "aafje-490407"}, - {match: "config set billing/quota_project hurozo", touch: quotaMarker}, + {match: "config get-value project", stdout: "example-project"}, + {match: "projects describe example-project", stdout: "example-project", missingStdout: "ERROR: USER_PROJECT_DENIED Caller does not have required permission to use project quota-project-123", requireFile: quotaMarker, exitCode: 1}, + {match: "config get-value billing/quota_project", stdout: "quota-project-123"}, + {match: "config set billing/quota_project example-project", touch: quotaMarker}, }) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) var stdout bytes.Buffer @@ -610,8 +817,8 @@ func TestEnsureGcloudSetupProjectAccessRepairsQuotaProject(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(stdout.String(), "uses quota project aafje-490407") || - !strings.Contains(stdout.String(), "Set quota project to hurozo now?") { + if !strings.Contains(stdout.String(), "uses quota project quota-project-123") || + !strings.Contains(stdout.String(), "Set quota project to example-project now?") { t.Fatalf("stdout = %q", stdout.String()) } } @@ -621,10 +828,10 @@ func TestEnsureGcloudSetupProjectAccessSelectsExistingProjectWhenUnset(t *testin writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ {match: "config get-value project"}, {match: "config get-value account", stdout: "dennis@example.com"}, - {match: "projects list", stdout: "hurozo Hurozo"}, - {match: "config set project hurozo"}, - {match: "config set billing/quota_project hurozo"}, - {match: "projects describe hurozo", stdout: "hurozo"}, + {match: "projects list", stdout: "example-project Example Project"}, + {match: "config set project example-project"}, + {match: "config set billing/quota_project example-project"}, + {match: "projects describe example-project", stdout: "example-project"}, }) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) var stdout bytes.Buffer @@ -633,7 +840,7 @@ func TestEnsureGcloudSetupProjectAccessSelectsExistingProjectWhenUnset(t *testin t.Fatal(err) } if !strings.Contains(stdout.String(), "has no project configured") || - !strings.Contains(stdout.String(), "1. hurozo - Hurozo") { + !strings.Contains(stdout.String(), "1. example-project - Example Project") { t.Fatalf("stdout = %q", stdout.String()) } } @@ -737,7 +944,7 @@ func TestEnsureGcloudSetupBillingLinksSelectedAccount(t *testing.T) { billingMarker := filepath.Join(t.TempDir(), "billing") writeFakeCLI(t, bin, "gcloud", []fakeCLIAction{ {match: "billing projects describe bgittest", stdout: "True", missingStdout: "False", requireFile: billingMarker, exitCode: 1}, - {match: "billing accounts list", stdout: "billingAccounts/123 Hurozo Billing true"}, + {match: "billing accounts list", stdout: "billingAccounts/123 Example Project Billing true"}, {match: "billing projects link bgittest --billing-account billingAccounts/123", touch: billingMarker}, }) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -761,7 +968,7 @@ func TestEnsureGcloudSetupBillingEnablesCloudBillingAPI(t *testing.T) { {match: "billing projects describe bgittest --configuration dennis --quiet --format=value(billingEnabled)", stdout: "False", missingStdout: "ERROR: SERVICE_DISABLED Cloud Billing API has not been used in project bgittest before or it is disabled.", requireFile: apiMarker, exitCode: 1}, {match: "services enable cloudbilling.googleapis.com --project bgittest", touch: apiMarker}, {match: "services list --enabled --project=bgittest", stdout: "cloudbilling.googleapis.com"}, - {match: "billing accounts list --configuration dennis --quiet", stdout: "billingAccounts/123 Hurozo Billing true"}, + {match: "billing accounts list --configuration dennis --quiet", stdout: "billingAccounts/123 Example Project Billing true"}, {match: "billing projects link bgittest --billing-account billingAccounts/123", touch: billingMarker}, }) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -876,3 +1083,251 @@ func TestBrokerDeleteGCPDeletesFunctionAndOptionalData(t *testing.T) { t.Fatalf("stdout = %q", stdout.String()) } } + +func TestSetupBrokerTeamRepoForActionPreservesTeamID(t *testing.T) { + repo, err := setupBrokerTeamRepoForAction(config{provider: "gcs"}, "mkt", "t_marketing") + if err != nil { + t.Fatal(err) + } + if repo.Logical != "mkt.git" || repo.TeamID != "t_marketing" || repo.Provider != "gcs" { + t.Fatalf("repo = %#v", repo) + } +} + +func TestSetupAvailableTeamUserChoicesGroupPendingAndExcludeMembers(t *testing.T) { + choices := setupBrokerUserChoicesFromUsers([]brokerUserInfo{{ + Username: "owner", + BrokerRole: "admin", + Keys: []brokerKey{{PublicKey: "ssh-ed25519 AAAA owner"}}, + }, { + Username: "pending", + BrokerRole: "user", + Pending: true, + }, { + Username: "developer", + BrokerRole: "user", + Keys: []brokerKey{{PublicKey: "ssh-ed25519 AAAA dev"}}, + }}, map[string]struct{}{"owner": {}}) + if len(choices) != 2 { + t.Fatalf("choices = %#v", choices) + } + if choices[0].Value != "developer" || choices[0].Group != "" { + t.Fatalf("first choice = %#v", choices[0]) + } + if choices[1].Value != "pending" || choices[1].Group != "pending users:" || choices[1].Label != "- pending *" { + t.Fatalf("pending choice = %#v", choices[1]) + } +} + +func TestSetupAvailableRepoInviteUsersExcludeMembersAndPendingInvites(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/broker/users/list": + _, _ = w.Write([]byte(`{"users":[ + {"username":"member","broker_role":"user","keys":[{"public_key":"ssh-ed25519 AAAA member"}]}, + {"username":"pending","broker_role":"user","pending":true}, + {"username":"available","broker_role":"user","keys":[{"public_key":"ssh-ed25519 AAAA available"}]} + ]}`)) + case "/keys/list": + _, _ = w.Write([]byte(`{"keys":[{"user":"member","role":"developer","public_key":"ssh-ed25519 AAAA member"}]}`)) + case "/keys/invite/list": + _, _ = w.Write([]byte(`{"invites":[{"user":"pending","role":"read"}]}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + choices, err := setupAvailableRepoInviteUserChoices(config{brokerURL: server.URL, provider: "gcs"}, "demo", "t_core") + if err != nil { + t.Fatal(err) + } + if len(choices) != 1 || choices[0].Value != "available" { + t.Fatalf("choices = %#v", choices) + } +} + +func TestSetupBrokerUserManagementChoicesNestUsers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/broker/users/list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"users":[ + {"username":"owner","broker_role":"owner","keys":[{"public_key":"ssh-ed25519 AAAA owner"}]}, + {"username":"piet","broker_role":"user","pending":true}, + {"username":"ada","broker_role":"admin","keys":[{"public_key":"ssh-ed25519 AAAA ada1"},{"public_key":"ssh-ed25519 AAAA ada2"}]} + ]}`)) + })) + defer server.Close() + + choices, err := setupBrokerUserManagementChoices(config{brokerURL: server.URL}) + if err != nil { + t.Fatal(err) + } + if choices[0].Value != "invite-user" { + t.Fatalf("first choice = %#v", choices[0]) + } + var sawAda, sawPiet bool + for _, choice := range choices { + switch choice.Value { + case "user:ada": + sawAda = true + if choice.Group != "users:" || !strings.Contains(choice.Help, "admin") || !strings.Contains(choice.Help, "2 keys") { + t.Fatalf("ada choice = %#v", choice) + } + case "user:piet": + sawPiet = true + if choice.Group != "users:" || !strings.Contains(choice.Label, "*") || !strings.Contains(choice.Help, "pending") { + t.Fatalf("piet choice = %#v", choice) + } + } + } + if !sawAda || !sawPiet { + t.Fatalf("choices = %#v", choices) + } +} + +func TestSetupBrokerOwnerUserOnlyShowsTransfer(t *testing.T) { + choices := setupBrokerUserActionChoices(brokerUserInfo{Username: "owner", BrokerRole: "owner", Keys: []brokerKey{{PublicKey: "ssh-ed25519 AAAA owner"}}}) + if len(choices) != 2 || choices[0].Value != "transfer-owner" || choices[1].Value != "back" { + t.Fatalf("choices = %#v", choices) + } + for _, choice := range choices { + switch choice.Value { + case "edit-role", "suspend", "unsuspend", "delete": + t.Fatalf("owner should not expose %q: %#v", choice.Value, choices) + } + } +} + +func TestSetupBrokerRegularUserShowsDelete(t *testing.T) { + choices := setupBrokerUserActionChoices(brokerUserInfo{Username: "ada", BrokerRole: "user", Keys: []brokerKey{{PublicKey: "ssh-ed25519 AAAA ada"}}}) + var sawDelete bool + for _, choice := range choices { + if choice.Value == "delete" { + sawDelete = true + } + } + if !sawDelete { + t.Fatalf("choices missing delete: %#v", choices) + } +} + +func TestSetupRepoUserManagementChoicesListsDirectUsers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/keys/list": + _, _ = w.Write([]byte(`{"keys":[ + {"user":"ada","role":"read","public_key":"ssh-ed25519 AAAA ada1"}, + {"user":"ada","role":"developer","public_key":"ssh-ed25519 AAAA ada2"} + ]}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + choices, err := setupRepoAccessManagementChoices(config{brokerURL: server.URL, provider: "gcs"}, "demo", "t_core") + if err != nil { + t.Fatal(err) + } + var found bool + for _, choice := range choices { + if choice.Value == "user:ada" { + found = true + if choice.Group != "users:" || !strings.Contains(choice.Help, "developer") || !strings.Contains(choice.Help, "2 keys") { + t.Fatalf("ada choice = %#v", choice) + } + } + } + if !found { + t.Fatalf("choices = %#v", choices) + } +} + +func TestSetupManagedTeamMenuUsesNestedUsersAndRepositories(t *testing.T) { + var stdout bytes.Buffer + msg, err := runSetupManagedTeamWithRaw(config{}, "core", "core", bufio.NewReader(strings.NewReader("\x1b")), strings.NewReader("\x1b"), &stdout) + if !errors.Is(err, errSetupBack) { + t.Fatalf("err = %v, want errSetupBack", err) + } + if msg != "" { + t.Fatalf("msg = %q", msg) + } + rendered := stdout.String() + for _, want := range []string{"manage users", "manage repositories"} { + if !strings.Contains(rendered, want) { + t.Fatalf("missing %q:\n%s", want, rendered) + } + } + for _, reject := range []string{"list members", "add member", "edit member role", "remove member"} { + if strings.Contains(rendered, reject) { + t.Fatalf("unexpected flat member action %q:\n%s", reject, rendered) + } + } +} + +func TestSetupAvailableTeamUserSelectHandlesEmptyChoices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/broker/users/list": + _, _ = w.Write([]byte(`{"users":[{"id":"u_owner","username":"owner","broker_role":"owner","keys":[{"public_key":"ssh-ed25519 AAAA owner"}]}]}`)) + case "/teams/list": + _, _ = w.Write([]byte(`{"teams":[{"id":"t_core","name":"core","members":[{"user_id":"u_owner","username":"owner","role":"admin"}]}]}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + var stdout bytes.Buffer + got, ok, err := runSetupAvailableTeamUserSelect(config{brokerURL: server.URL}, "t_core", bufio.NewReader(strings.NewReader("\n")), strings.NewReader("\n"), &stdout, setupBreadcrumb("Manage team", "core", "Manage users", "Add user")) + if err != nil { + t.Fatal(err) + } + if ok || got != "" { + t.Fatalf("ok=%v got=%q", ok, got) + } + if !strings.Contains(stdout.String(), "No users are available to add.") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSetupAvailableRepoTeamChoicesExcludeAttachedTeams(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/teams/list": + _, _ = w.Write([]byte(`{"teams":[ + {"id":"t_core","name":"core"}, + {"id":"t_attached","name":"attached"}, + {"id":"t_available","name":"available"} + ]}`)) + case "/repos/list": + _, _ = w.Write([]byte(`{"repos":[{"logical":"demo.git","repo":{"logical":"demo.git","team_id":"t_core"},"teams":[{"id":"t_attached","role":"read"}]}]}`)) + case "/repo/teams/list": + _, _ = w.Write([]byte(`{"teams":[{"id":"t_attached","role":"read"}]}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + choices, err := setupAvailableRepoTeamChoices(config{brokerURL: server.URL, provider: "gcs"}, "demo", "t_core") + if err != nil { + t.Fatal(err) + } + if len(choices) != 1 || choices[0].Value != "t_available" { + t.Fatalf("choices = %#v", choices) + } +} + +func TestSetupAcceptCommandFromOutputOmitsSeparateCode(t *testing.T) { + output := "invite pending\n\nCode:\n bgitinv_abc\n\nGive this command to the user:\n bgit admin accept-invite bgitinv_abc\n" + got := setupAcceptCommandFromOutput(output) + if got != "bgit admin accept-invite bgitinv_abc" { + t.Fatalf("command = %q", got) + } + if strings.Contains(got, "\n") || strings.Contains(got, "Code:") { + t.Fatalf("unexpected redundant content in %q", got) + } +} diff --git a/ssh.go b/ssh.go index aafb65a..da31050 100644 --- a/ssh.go +++ b/ssh.go @@ -538,8 +538,13 @@ type brokerRepo struct { Prefix string `json:"prefix"` Origin string `json:"origin"` Logical string `json:"logical,omitempty"` + Host string `json:"host,omitempty"` + TeamID string `json:"team_id,omitempty"` } +const coreTeamID = "t_core" +const coreTeamName = "core" + type brokerKey struct { User string `json:"user"` Role string `json:"role"` @@ -587,18 +592,26 @@ type brokerKeysResponse struct { Keys []brokerKey `json:"keys"` } -func brokerUpsertLogicalRepo(brokerURL, provider, logicalRepo string) error { +func brokerUpsertLogicalRepo(brokerURL, provider, logicalRepo string, teamID ...string) error { logical, err := normalizeLogicalRepoName(logicalRepo) if err != nil { return err } + team := "" + if len(teamID) > 0 { + team = strings.TrimSpace(teamID[0]) + } cfg := config{ provider: provider, prefix: logical, logicalRepo: logical, origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logical), } - req := brokerRepoRequest{Repo: repoForBroker(cfg)} + repo := repoForBroker(cfg) + if team != "" { + repo.TeamID = team + } + req := brokerRepoRequest{Repo: repo} return brokerPost(brokerURL, "/repos/upsert", req, nil) } @@ -736,7 +749,16 @@ func repoForBroker(cfg config) brokerRepo { Prefix: strings.Trim(cfg.prefix, "/"), Origin: cfg.origin, Logical: logical, + TeamID: brokerTeamIDForConfig(cfg), + } +} + +func brokerTeamIDForConfig(cfg config) string { + teamID := strings.TrimSpace(cfg.teamID) + if teamID == "" && strings.TrimSpace(cfg.logicalRepo) != "" { + teamID = coreTeamID } + return teamID } func brokerPost(brokerURL, path string, req any, resp any) error { diff --git a/testsuite/aws/init.sh b/testsuite/aws/init.sh index 5aff5f3..9f233c8 100755 --- a/testsuite/aws/init.sh +++ b/testsuite/aws/init.sh @@ -8,7 +8,26 @@ out="$(git -C "$dir" config --get bucketgit.logicalRepo)" assert_contains "$out" "$repo" out="$(git -C "$dir" config --get bucketgit.broker)" assert_contains "$out" "http" +out="$(git -C "$dir" config --get bucketgit.team)" +assert_contains "$out" "t_core" bad_dir="$(new_workdir aws init-path-rejected)" -out="$(expect_failure "$BGIT" init --noninteractive --repo team/app --profile "$AWS_PROFILE" "${CONFIG_ARGS[@]}" "$bad_dir")" +out="$(expect_failure "$BGIT" init --noninteractive --repo team/app --profile "$AWS_PROFILE" --team core "${CONFIG_ARGS[@]}" "$bad_dir")" assert_contains "$out" "logical repo names must be flat" + +missing_dir="$(new_workdir aws init-missing-repo)" +out="$(expect_failure "$BGIT" init --noninteractive --repo "$(new_repo_name aws init-missing-repo)" --profile "$AWS_PROFILE" --team core "${CONFIG_ARGS[@]}" "$missing_dir")" +assert_contains "$out" "repository not found" +assert_file_not_exists "$missing_dir/.git" + +created_repo="$(new_repo_name aws explicit-create)" +out="$(expect_success "$BGIT" --profile "$AWS_PROFILE" admin repo create --team core "$created_repo")" +assert_contains "$out" "created repository" +created_dir="$(new_workdir aws explicit-create)" +expect_success "$BGIT" init --noninteractive --repo "$created_repo" --profile "$AWS_PROFILE" --team core "${CONFIG_ARGS[@]}" "$created_dir" >/dev/null +out="$(expect_failure "$BGIT" --profile "$AWS_PROFILE" admin repo create --team core "$created_repo")" +assert_contains "$out" "repository already exists" +out="$(expect_failure "$BGIT" --profile "$AWS_PROFILE" admin repo create --team core --role madeup "$(new_repo_name aws invalid-role)")" +assert_contains "$out" "invalid repo team role" +out="$(expect_failure "$BGIT" --profile "$AWS_PROFILE" admin repo create --team missing-team "$(new_repo_name aws invalid-team)")" +assert_contains "$out" "team not found" diff --git a/testsuite/aws/teams_discovery.sh b/testsuite/aws/teams_discovery.sh new file mode 100644 index 0000000..72ba744 --- /dev/null +++ b/testsuite/aws/teams_discovery.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/teams_discovery.sh" aws diff --git a/testsuite/aws/whoami_repos.sh b/testsuite/aws/whoami_repos.sh index 61800ea..0a18a79 100755 --- a/testsuite/aws/whoami_repos.sh +++ b/testsuite/aws/whoami_repos.sh @@ -3,7 +3,38 @@ source "$(dirname "$0")/../lib/testlib.sh" init_output="$(init_bgit_repo aws whoami)" dir="$(printf "%s\n" "$init_output" | sed -n "1p")" repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +repo_display="${repo%.git}" +add_key_to_repo "$dir" developer developer developer out="$(run_in "$dir" whoami)" assert_contains "$out" "role" +assert_contains "$out" "role: admin" +out="$(run_in "$dir" whoami --json --refresh)" +assert_contains "$out" "\"role\": \"admin\"" +assert_contains "$out" "\"capabilities\"" +out="$(run_in "$dir" whoami --all)" +assert_contains "$out" "$repo" +out="$(run_in "$dir" whoami --all --json)" +assert_contains "$out" "\"repos\"" +assert_contains "$out" "$repo" out="$(run_in "$dir" repos mine)" assert_contains "$out" "$repo" +out="$(run_in "$dir" repos mine --json)" +assert_contains "$out" "\"repos\"" +assert_contains "$out" "$repo" +out="$(run_in "$dir" admin repo list)" +assert_contains "$out" "$repo_display" +out="$(run_in "$dir" admin repo info)" +assert_contains "$out" "repository:" +assert_contains "$out" "visibility:" +out="$(run_in "$dir" admin teams repo list)" +assert_contains "$out" "t_core" +out="$(run_in "$dir" janitor members reindex)" +assert_contains "$out" "reindexed broker membership" +out="$(expect_failure_no_agent "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" +out="$(expect_failure_no_agent "$dir" repos mine)" +assert_contains "$out" "no SSH agent keys available" +out="$(expect_failure_in_as developer "$dir" admin repo list)" +assert_contains "$out" "broker admin SSH signature required" +out="$(expect_failure_in_as developer "$dir" janitor members reindex)" +assert_contains "$out" "admin SSH signature required" diff --git a/testsuite/gcp/init.sh b/testsuite/gcp/init.sh index c8bb228..c622a9f 100755 --- a/testsuite/gcp/init.sh +++ b/testsuite/gcp/init.sh @@ -8,7 +8,26 @@ out="$(git -C "$dir" config --get bucketgit.logicalRepo)" assert_contains "$out" "$repo" out="$(git -C "$dir" config --get bucketgit.broker)" assert_contains "$out" "http" +out="$(git -C "$dir" config --get bucketgit.team)" +assert_contains "$out" "t_core" bad_dir="$(new_workdir gcp init-path-rejected)" -out="$(expect_failure "$BGIT" init --noninteractive --repo team/app --profile "$GCP_PROFILE" "${CONFIG_ARGS[@]}" "$bad_dir")" +out="$(expect_failure "$BGIT" init --noninteractive --repo team/app --profile "$GCP_PROFILE" --team core "${CONFIG_ARGS[@]}" "$bad_dir")" assert_contains "$out" "logical repo names must be flat" + +missing_dir="$(new_workdir gcp init-missing-repo)" +out="$(expect_failure "$BGIT" init --noninteractive --repo "$(new_repo_name gcp init-missing-repo)" --profile "$GCP_PROFILE" --team core "${CONFIG_ARGS[@]}" "$missing_dir")" +assert_contains "$out" "repository not found" +assert_file_not_exists "$missing_dir/.git" + +created_repo="$(new_repo_name gcp explicit-create)" +out="$(expect_success "$BGIT" --profile "$GCP_PROFILE" admin repo create --team core "$created_repo")" +assert_contains "$out" "created repository" +created_dir="$(new_workdir gcp explicit-create)" +expect_success "$BGIT" init --noninteractive --repo "$created_repo" --profile "$GCP_PROFILE" --team core "${CONFIG_ARGS[@]}" "$created_dir" >/dev/null +out="$(expect_failure "$BGIT" --profile "$GCP_PROFILE" admin repo create --team core "$created_repo")" +assert_contains "$out" "repository already exists" +out="$(expect_failure "$BGIT" --profile "$GCP_PROFILE" admin repo create --team core --role madeup "$(new_repo_name gcp invalid-role)")" +assert_contains "$out" "invalid repo team role" +out="$(expect_failure "$BGIT" --profile "$GCP_PROFILE" admin repo create --team missing-team "$(new_repo_name gcp invalid-team)")" +assert_contains "$out" "team not found" diff --git a/testsuite/gcp/teams_discovery.sh b/testsuite/gcp/teams_discovery.sh new file mode 100644 index 0000000..943050e --- /dev/null +++ b/testsuite/gcp/teams_discovery.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +bash "$(dirname "$0")/../lib/cases/teams_discovery.sh" gcp diff --git a/testsuite/gcp/whoami_repos.sh b/testsuite/gcp/whoami_repos.sh index e2fac39..08fdae6 100755 --- a/testsuite/gcp/whoami_repos.sh +++ b/testsuite/gcp/whoami_repos.sh @@ -3,7 +3,38 @@ source "$(dirname "$0")/../lib/testlib.sh" init_output="$(init_bgit_repo gcp whoami)" dir="$(printf "%s\n" "$init_output" | sed -n "1p")" repo="$(printf "%s\n" "$init_output" | sed -n "2p")" +repo_display="${repo%.git}" +add_key_to_repo "$dir" developer developer developer out="$(run_in "$dir" whoami)" assert_contains "$out" "role" +assert_contains "$out" "role: admin" +out="$(run_in "$dir" whoami --json --refresh)" +assert_contains "$out" "\"role\": \"admin\"" +assert_contains "$out" "\"capabilities\"" +out="$(run_in "$dir" whoami --all)" +assert_contains "$out" "$repo" +out="$(run_in "$dir" whoami --all --json)" +assert_contains "$out" "\"repos\"" +assert_contains "$out" "$repo" out="$(run_in "$dir" repos mine)" assert_contains "$out" "$repo" +out="$(run_in "$dir" repos mine --json)" +assert_contains "$out" "\"repos\"" +assert_contains "$out" "$repo" +out="$(run_in "$dir" admin repo list)" +assert_contains "$out" "$repo_display" +out="$(run_in "$dir" admin repo info)" +assert_contains "$out" "repository:" +assert_contains "$out" "visibility:" +out="$(run_in "$dir" admin teams repo list)" +assert_contains "$out" "t_core" +out="$(run_in "$dir" janitor members reindex)" +assert_contains "$out" "reindexed broker membership" +out="$(expect_failure_no_agent "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" +out="$(expect_failure_no_agent "$dir" repos mine)" +assert_contains "$out" "no SSH agent keys available" +out="$(expect_failure_in_as developer "$dir" admin repo list)" +assert_contains "$out" "broker admin SSH signature required" +out="$(expect_failure_in_as developer "$dir" janitor members reindex)" +assert_contains "$out" "admin SSH signature required" diff --git a/testsuite/lib/cases/invites_ownership.sh b/testsuite/lib/cases/invites_ownership.sh index fd02e05..58250b6 100644 --- a/testsuite/lib/cases/invites_ownership.sh +++ b/testsuite/lib/cases/invites_ownership.sh @@ -12,6 +12,8 @@ out="$(run_in "$dir" admin invite-user --broker "$broker" --user invited-dev --r assert_contains "$out" "bgit admin accept-invite" invite_code="$(printf '%s\n' "$out" | awk '/accept-invite/ {print $NF; exit}')" [[ "$invite_code" == bgitinv_* ]] || fail "invite code not found in output: $out" +out="$(expect_failure "$BGIT" admin invite-user --broker "$broker" --user bad-role --role madeup "$repo")" +assert_contains "$out" "invalid role" out="$(expect_failure "$BGIT" admin invite-user --broker "$broker" --user invited-dev --role read "$repo")" assert_contains "$out" "invite already pending for user" cancel_out="$(run_in "$dir" admin invite-user --broker "$broker" --user cancelled-dev --role developer "$repo")" @@ -23,7 +25,7 @@ assert_contains "$out" "invite is not pending or has expired" cancel_out="$(run_in "$dir" admin invite-user --broker "$broker" --user code-cancelled --role read "$repo")" cancel_code="$(printf '%s\n' "$cancel_out" | awk '/accept-invite/ {print $NF; exit}')" out="$(expect_failure "$BGIT" admin cancel-invite "$cancel_code")" -assert_contains "$out" "usage: bgit admin cancel-invite --broker URL --user USER REPO" +assert_contains "$out" "usage: bgit admin cancel-invite --broker URL [--team TEAM] --user USER REPO" out="$(run_in "$dir" admin cancel-invite --user code-cancelled)" assert_contains "$out" "cancelled invite for code-cancelled" out="$(with_agent_key maintainer expect_failure "$BGIT" admin accept-invite "$cancel_code")" @@ -38,10 +40,47 @@ out="$(run_in_as outsider "$dir" whoami --refresh)" assert_contains "$out" "user: invited-dev" assert_contains "$out" "role: developer" +out="$(run_in "$dir" admin invite-broker-user --broker "$broker" --user pending-team --role user)" +assert_contains "$out" "bgit admin accept-broker-invite" +broker_invite_code="$(printf '%s\n' "$out" | awk '/accept-broker-invite/ {print $NF; exit}')" +[[ "$broker_invite_code" == bgituser_* ]] || fail "broker invite code not found in output: $out" +out="$(expect_failure "$BGIT" admin invite-broker-user --broker "$broker" --user wrong-role --role madeup)" +assert_contains "$out" "invalid broker role" +cancel_broker_out="$(run_in "$dir" admin invite-broker-user --broker "$broker" --user cancelled-broker --role user)" +cancel_broker_code="$(printf '%s\n' "$cancel_broker_out" | awk '/accept-broker-invite/ {print $NF; exit}')" +out="$(run_in "$dir" admin cancel-broker-invite --broker "$broker" --user cancelled-broker)" +assert_contains "$out" "cancelled broker invite for cancelled-broker" +out="$(with_agent_key read expect_failure "$BGIT" admin accept-broker-invite "$cancel_broker_code")" +assert_contains "$out" "broker user invite is not pending" +out="$(run_in "$dir" admin broker-users list)" +assert_not_contains "$out" "cancelled-broker" +out="$(run_in_as read "$dir" repos mine --json)" +assert_not_contains "$out" "$repo" +team_out="$(run_in "$dir" admin teams create pending-team-access)" +team_id="$(printf '%s\n' "$team_out" | awk -F'[()]' '{print $2; exit}')" +[[ "$team_id" == t_* ]] || fail "team id not found in output: $team_out" +run_in "$dir" admin teams member add "$team_id" pending-team --role developer >/dev/null +run_in "$dir" admin teams repo add "$team_id" developer >/dev/null +out="$(expect_failure_in_as read "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" +out="$(with_agent_key read "$BGIT" admin accept-broker-invite "$broker_invite_code")" +assert_contains "$out" "accepted broker invite for pending-team as user" +out="$(run_in_as read "$dir" whoami --refresh)" +assert_contains "$out" "user: pending-team" +assert_contains "$out" "role: developer" +out="$(run_in_as read "$dir" whoami --all --json)" +assert_contains "$out" "\"user\": \"pending-team\"" +assert_contains "$out" "\"role\": \"developer\"" +out="$(run_in_as read "$dir" repos mine --json)" +assert_contains "$out" "\"user\": \"pending-team\"" +assert_contains "$out" "$repo" + out="$(run_in "$dir" admin confirm-ownership-transfer --broker "$broker" "$repo")" assert_contains "$out" "ownership transfer pending" transfer_code="$(printf '%s\n' "$out" | awk '/accept-ownership-transfer/ {print $NF; exit}')" [[ "$transfer_code" == bgitot_* ]] || fail "ownership transfer code not found in output: $out" +out="$(expect_failure "$BGIT" admin confirm-ownership-transfer --broker "$broker" does-not-exist)" +assert_contains "$out" "repository not found" out="$(expect_failure "$BGIT" admin confirm-ownership-transfer --broker "$broker" "$repo")" assert_contains "$out" "ownership transfer already pending" out="$(run_in "$dir" admin cancel-ownership-transfer --broker "$broker" "$repo")" @@ -51,11 +90,14 @@ assert_contains "$out" "ownership transfer is not pending or has expired" out="$(run_in "$dir" admin confirm-ownership-transfer --broker "$broker" "$repo")" transfer_code="$(printf '%s\n' "$out" | awk '/accept-ownership-transfer/ {print $NF; exit}')" -out="$(with_agent_key outsider "$BGIT" admin accept-ownership-transfer "$transfer_code")" +out="$(with_agent_key outsider expect_failure "$BGIT" admin accept-ownership-transfer "$transfer_code")" +assert_contains "$out" "SSH key already belongs to user invited-dev" +out="$(with_agent_key maintainer "$BGIT" admin accept-ownership-transfer "$transfer_code")" assert_contains "$out" "accepted ownership for $repo" -out="$(run_in_as outsider "$dir" whoami --refresh)" +out="$(run_in_as maintainer "$dir" whoami --refresh)" assert_contains "$out" "role: owner" out="$(run_in "$dir" whoami --refresh)" assert_contains "$out" "role: admin" -out="$(expect_failure "$BGIT" admin confirm-ownership-transfer --broker "$broker" "$repo")" -assert_contains "$out" "owner SSH signature required" +out="$(run_in "$dir" admin confirm-ownership-transfer --broker "$broker" "$repo")" +assert_contains "$out" "ownership transfer pending" +run_in "$dir" admin cancel-ownership-transfer --broker "$broker" "$repo" >/dev/null diff --git a/testsuite/lib/cases/local_more_porcelain.sh b/testsuite/lib/cases/local_more_porcelain.sh index 8a60ca7..04de55a 100644 --- a/testsuite/lib/cases/local_more_porcelain.sh +++ b/testsuite/lib/cases/local_more_porcelain.sh @@ -2,9 +2,8 @@ set -euo pipefail source "$(dirname "$0")/../testlib.sh" -dir="$(new_workdir local porcelain-more)" -"$BGIT" init --noninteractive --repo local-porcelain-more --profile "$GCP_PROFILE" "${CONFIG_ARGS[@]}" "$dir" >/dev/null -init_local_git_identity "$dir" +init_output="$(init_bgit_repo gcp local-porcelain-more)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" commit_file "$dir" README.md "line one" "initial" run_in "$dir" switch -c feature >/dev/null diff --git a/testsuite/lib/cases/public_private_access.sh b/testsuite/lib/cases/public_private_access.sh index 0bec654..73d40cb 100644 --- a/testsuite/lib/cases/public_private_access.sh +++ b/testsuite/lib/cases/public_private_access.sh @@ -14,9 +14,9 @@ run_in "$dir" push -u origin main >/dev/null private_no_key="$SUITE_ROOT/$provider/repo/public-private-no-key-private-$RUN_ID" private_unknown="$SUITE_ROOT/$provider/repo/public-private-unknown-private-$RUN_ID" out="$(without_ssh_identity expect_failure "$BGIT" clone "$clone_url" "$private_no_key")" -assert_contains "$out" "broker denied read access" +assert_contains "$out" "read SSH signature required" out="$(with_agent_key outsider expect_failure "$BGIT" clone "$clone_url" "$private_unknown")" -assert_contains "$out" "broker denied read access" +assert_contains "$out" "read SSH signature required" run_in "$dir" admin repo visibility public >/dev/null @@ -29,6 +29,16 @@ with_agent_key outsider expect_success "$BGIT" clone "$clone_url" "$public_unkno assert_file_exists "$public_unknown/README.md" assert_contains "$(cat "$public_unknown/README.md")" "public private access" +core_clone="$SUITE_ROOT/$provider/repo/public-private-core-url-$RUN_ID" +core_url="${broker%/}/core/${repo%.git}/$repo" +without_ssh_identity expect_success "$BGIT" clone "$core_url" "$core_clone" >/dev/null +assert_file_exists "$core_clone/README.md" +assert_contains "$(cat "$core_clone/README.md")" "public private access" + +bad_core_url="${broker%/}/core/not-$repo/$repo" +out="$(without_ssh_identity expect_failure "$BGIT" clone "$bad_core_url" "$SUITE_ROOT/$provider/repo/public-private-bad-core-url-$RUN_ID")" +assert_contains "$out" "middle repo segment must match" + run_in "$dir" admin repo visibility private >/dev/null out="$(cd "$public_no_key" && without_ssh_identity expect_failure "$BGIT" ls-remote)" assert_contains "$out" "read SSH signature required" diff --git a/testsuite/lib/cases/roles_permissions.sh b/testsuite/lib/cases/roles_permissions.sh index dffd967..d0a2138 100644 --- a/testsuite/lib/cases/roles_permissions.sh +++ b/testsuite/lib/cases/roles_permissions.sh @@ -30,10 +30,8 @@ out="$(run_in_as admin "$dir" admin keys list)" assert_contains "$out" "developer" owner_fp="$(key_fingerprint owner)" -out="$(expect_failure_in_as admin "$dir" admin keys remove "$owner_fp")" -assert_contains "$out" "owners cannot be removed or suspended" -out="$(expect_failure_in_as admin "$dir" admin keys suspend "$owner_fp")" -assert_contains "$out" "owners cannot be removed or suspended" +out="$(run_in_as admin "$dir" admin keys list)" +assert_contains "$out" $'owner\tadmin' developer_fp="$(key_fingerprint developer)" run_in_as admin "$dir" admin keys suspend "$developer_fp" >/dev/null @@ -45,4 +43,3 @@ assert_contains "$out" "SSH signature required" out="$(expect_failure_in_as outsider "$dir" whoami --refresh)" assert_contains "$out" "SSH signature required" - diff --git a/testsuite/lib/cases/teams_discovery.sh b/testsuite/lib/cases/teams_discovery.sh new file mode 100644 index 0000000..b74aa31 --- /dev/null +++ b/testsuite/lib/cases/teams_discovery.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/../testlib.sh" + +provider="$1" +scoped_output="$(init_bgit_repo_no_owner_access "$provider" scoped-owner)" +scoped_dir="$(printf "%s\n" "$scoped_output" | sed -n "1p")" +out="$(run_in_as owner "$scoped_dir" whoami --refresh)" +assert_contains "$out" "role: none" +assert_contains "$out" "admin_keys" +run_in "$scoped_dir" admin keys add --no-agent --key "$(key_path owner.pub)" --user owner --role read >/dev/null +out="$(expect_failure_in_as owner "$scoped_dir" admin keys add --no-agent --key "$(key_path owner.pub)" --user other-owner --role read)" +assert_contains "$out" "SSH key already belongs to user owner" +out="$(run_in_as owner "$scoped_dir" whoami --refresh)" +assert_contains "$out" "role: read" +commit_file "$scoped_dir" README.md "scoped owner" "scoped owner" +out="$(expect_failure_in_as owner "$scoped_dir" push)" +assert_contains "$out" "write SSH signature required" + +init_output="$(init_bgit_repo "$provider" teams)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" + +out="$(run_in "$dir" admin teams list)" +assert_contains "$out" "t_core" +assert_contains "$out" "core" + +out="$(run_in "$dir" admin broker-users upsert teamdev --role user --key "$(key_path developer.pub)")" +assert_contains "$out" "upserted broker user teamdev as user" +out="$(expect_failure_in_as owner "$dir" admin broker-users upsert duplicate-dev --role user --key "$(key_path developer.pub)")" +assert_contains "$out" "SSH key already belongs to broker user teamdev" +out="$(run_in "$dir" admin broker-users upsert brokeradmin --role admin --key "$(key_path admin.pub)")" +assert_contains "$out" "upserted broker user brokeradmin as admin" +out="$(run_in_as admin "$dir" admin broker-users upsert delegated-admin --role admin --key "$(key_path triage.pub)")" +assert_contains "$out" "upserted broker user delegated-admin as admin" +out="$(run_in_as triage "$dir" admin teams create delegated-active)" +assert_contains "$out" "created team delegated-active" +out="$(run_in "$dir" admin broker-users upsert delegated-admin --role admin --suspended true)" +assert_contains "$out" "upserted broker user delegated-admin as admin" +out="$(run_in "$dir" admin broker-users list)" +assert_contains "$out" "delegated-admin" +assert_contains "$out" "suspended" +out="$(expect_failure_in_as triage "$dir" admin teams create delegated-suspended)" +assert_contains "$out" "broker admin SSH signature required" +out="$(run_in "$dir" admin broker-users upsert delegated-admin --role admin --suspended false)" +assert_contains "$out" "upserted broker user delegated-admin as admin" +out="$(run_in_as triage "$dir" admin teams create delegated-unsuspended)" +assert_contains "$out" "created team delegated-unsuspended" +out="$(expect_failure_in_as admin "$dir" admin broker-users upsert forbidden-owner --role owner --key "$(key_path read.pub)")" +assert_contains "$out" "invalid broker role" +out="$(cd "$dir" && expect_failure "$BGIT" admin broker-users upsert forbidden-owner --role owner --key "$(key_path read.pub)")" +assert_contains "$out" "invalid broker role" +out="$(cd "$dir" && expect_failure "$BGIT" admin broker-users upsert invalid-role --role madeup --key "$(key_path read.pub)")" +assert_contains "$out" "invalid broker role" +out="$(expect_failure_in_as owner "$dir" admin broker-users upsert owner --role user)" +assert_contains "$out" "broker owner cannot be reassigned or suspended" +out="$(expect_failure_in_as owner "$dir" admin broker-users upsert owner --role owner)" +assert_contains "$out" "invalid broker role" +out="$(expect_failure_in_as owner "$dir" admin broker-users delete owner)" +assert_contains "$out" "broker owner cannot be deleted" + +out="$(run_in "$dir" admin teams create platform)" +assert_contains "$out" "created team platform" +team_id="$(printf '%s\n' "$out" | sed -n 's/.*(\(t_[^)]*\)).*/\1/p')" +[[ -n "$team_id" ]] || fail "team id not found in output: $out" +out="$(expect_failure_in_as developer "$dir" admin teams delete "$team_id")" +assert_contains "$out" "broker admin SSH signature required" +out="$(expect_failure "$BGIT" --profile "$(provider_profile "$provider")" admin teams delete t_core)" +assert_contains "$out" "core team cannot be deleted" + +out="$(run_in_as admin "$dir" admin teams create admin-created)" +assert_contains "$out" "created team admin-created" + +out="$(expect_failure_in_as developer "$dir" admin broker-users upsert denied --role user --key "$(key_path read.pub)")" +assert_contains "$out" "broker admin SSH signature required" +out="$(expect_failure_in_as developer "$dir" admin teams create denied)" +assert_contains "$out" "broker admin SSH signature required" + +out="$(run_in "$dir" admin broker-users upsert tempdel --role user)" +assert_contains "$out" "upserted broker user tempdel as user" +run_in "$dir" admin teams member add "$team_id" tempdel --role developer >/dev/null +run_in "$dir" admin keys add --no-agent --key "$(key_path read.pub)" --user tempdel --role developer >/dev/null +out="$(run_in "$dir" admin broker-users delete tempdel)" +assert_contains "$out" "deleted broker user tempdel" +out="$(run_in "$dir" admin broker-users list)" +assert_not_contains "$out" "tempdel" +out="$(run_in "$dir" admin teams list)" +assert_not_contains "$out" "tempdel" +out="$(run_in "$dir" admin keys list)" +assert_not_contains "$out" "tempdel" +out="$(expect_failure_in_as read "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" + +run_in "$dir" admin teams member add "$team_id" teamdev --role read >/dev/null +run_in_as admin "$dir" admin teams member add "$team_id" brokeradmin --role developer >/dev/null +out="$(run_in "$dir" admin teams list)" +assert_contains "$out" "teamdev:read" +assert_contains "$out" "brokeradmin:developer" +run_in "$dir" admin teams repo add "$team_id" read >/dev/null +run_in "$dir" admin teams repo add "$team_id" read >/dev/null +out="$(run_in "$dir" admin teams repo list)" +assert_contains "$out" "$team_id" +grant_count="$(printf '%s\n' "$out" | grep -c "^$team_id[[:space:]]")" +[[ "$grant_count" == "1" ]] || fail "expected idempotent team repo grant; got: $out" +out="$(expect_failure_in_as developer "$dir" admin teams repo list)" +assert_contains "$out" "admin SSH signature required" +out="$(expect_failure "$BGIT" --profile "$(provider_profile "$provider")" admin teams repo add "$team_id" read)" +assert_contains "$out" "repo is required" +out="$(expect_failure_in_as owner "$dir" admin teams repo add missing-team read)" +assert_contains "$out" "team not found" +out="$(run_in_as owner "$dir" admin teams repo remove missing-team)" +assert_contains "$out" "detached team missing-team" +original_logical="$(git -C "$dir" config --get bucketgit.logicalRepo)" +git -C "$dir" config bucketgit.logicalRepo "$(new_repo_name "$provider" missing-team-grant).git" +out="$(expect_failure_in_as owner "$dir" admin teams repo list)" +assert_contains "$out" "repository not found" +out="$(expect_failure_in_as owner "$dir" admin teams repo add "$team_id" read)" +assert_contains "$out" "repository not found" +out="$(expect_failure_in_as owner "$dir" admin repo info)" +assert_contains "$out" "repository not found" +git -C "$dir" config bucketgit.logicalRepo "$original_logical" + +out="$(run_in "$dir" admin teams list)" +assert_contains "$out" "$team_id" +assert_contains "$out" "platform" + +commit_file "$dir" README.md "owner seed" "owner seed" +run_in "$dir" push >/dev/null + +with_agent_key developer bash -c ' + set -euo pipefail + dir="$1" + cd "$dir" + printf "team read should not write\n" > TEAM.md + "$BGIT" add TEAM.md + "$BGIT" commit -m "team read should not write" >/dev/null + if out="$("$BGIT" push 2>&1)"; then + printf "%s\n" "$out" >&2 + exit 99 + fi + [[ "$out" == *"write SSH signature required"* ]] +' _ "$dir" + +run_in "$dir" admin teams repo add "$team_id" developer >/dev/null +with_agent_key developer bash -c ' + set -euo pipefail + dir="$1" + cd "$dir" + printf "team member read should still not write\n" >> TEAM.md + "$BGIT" add TEAM.md + "$BGIT" commit -m "team member read should still not write" >/dev/null + if out="$("$BGIT" push 2>&1)"; then + printf "%s\n" "$out" >&2 + exit 99 + fi + [[ "$out" == *"write SSH signature required"* ]] +' _ "$dir" + +run_in "$dir" admin teams member add "$team_id" teamdev --role developer >/dev/null +out="$(run_in "$dir" admin teams list)" +assert_contains "$out" "teamdev:developer" +run_in "$dir" admin teams repo add "$team_id" developer >/dev/null + +with_agent_key developer bash -c ' + set -euo pipefail + dir="$1" + cd "$dir" + printf "team write\n" >> TEAM.md + "$BGIT" add TEAM.md + "$BGIT" commit -m "team write" >/dev/null + "$BGIT" push >/dev/null +' _ "$dir" + +out="$(run_in_as developer "$dir" whoami)" +assert_contains "$out" "teamdev" +assert_contains "$out" "developer" + +out="$(expect_failure_in_as developer "$dir" admin teams repo add "$team_id" admin)" +assert_contains "$out" "admin SSH signature required" + +run_in "$dir" admin teams repo remove "$team_id" >/dev/null +out="$(expect_failure_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" + +run_in "$dir" admin teams repo add "$team_id" developer >/dev/null +run_in "$dir" admin teams member remove "$team_id" teamdev >/dev/null +out="$(expect_failure_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "SSH signature required" + +run_in "$dir" admin teams member add "$team_id" teamdev --role developer >/dev/null +out="$(run_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "teamdev" +assert_contains "$out" "role: developer" + +out="$(run_in "$dir" admin teams create qa)" +assert_contains "$out" "created team qa" +second_team_id="$(printf '%s\n' "$out" | sed -n 's/.*(\(t_[^)]*\)).*/\1/p')" +[[ -n "$second_team_id" ]] || fail "second team id not found in output: $out" +run_in "$dir" admin teams member add "$second_team_id" teamdev --role admin >/dev/null +run_in "$dir" admin teams repo add "$second_team_id" admin >/dev/null +out="$(run_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "role: admin" +run_in "$dir" admin teams repo remove "$second_team_id" >/dev/null +out="$(run_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "role: developer" +run_in "$dir" admin teams repo add "$second_team_id" admin >/dev/null +run_in "$dir" admin teams member remove "$second_team_id" teamdev >/dev/null +out="$(run_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "role: developer" +run_in "$dir" admin teams delete "$second_team_id" >/dev/null +out="$(run_in "$dir" admin teams list)" +assert_not_contains "$out" "$second_team_id" + +run_in "$dir" admin teams member add "$team_id" teamdev --role owner >/dev/null +run_in "$dir" admin teams repo add "$team_id" owner >/dev/null +out="$(run_in_as developer "$dir" whoami --refresh)" +assert_contains "$out" "role: owner" +assert_contains "$out" "owner_transfer" diff --git a/testsuite/lib/testlib.sh b/testsuite/lib/testlib.sh index e8e1ecd..73ab195 100755 --- a/testsuite/lib/testlib.sh +++ b/testsuite/lib/testlib.sh @@ -85,13 +85,22 @@ init_local_git_identity() { } init_bgit_repo() { + local init_output dir + init_output="$(init_bgit_repo_no_owner_access "$@")" + dir="$(printf "%s\n" "$init_output" | sed -n "1p")" + add_key_to_repo "$dir" owner admin owner + printf '%s' "$init_output" +} + +init_bgit_repo_no_owner_access() { local provider="$1" local suffix="$2" local profile repo dir profile="$(provider_profile "$provider")" repo="$(new_repo_name "$provider" "$suffix")" dir="$(new_workdir "$provider" "$suffix")" - expect_success "$BGIT" init --noninteractive --repo "$repo" --profile "$profile" "${CONFIG_ARGS[@]}" "$dir" >/dev/null + expect_success "$BGIT" --profile "$profile" admin repo create --team core "$repo" >/dev/null + expect_success "$BGIT" init --noninteractive --repo "$repo" --profile "$profile" --team core "${CONFIG_ARGS[@]}" "$dir" >/dev/null init_local_git_identity "$dir" printf '%s\n%s\n' "$dir" "$repo.git" } diff --git a/testsuite/local/add.sh b/testsuite/local/add.sh index 83191cd..d736314 100755 --- a/testsuite/local/add.sh +++ b/testsuite/local/add.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash source "$(dirname "$0")/../lib/testlib.sh" -dir="$(new_workdir local add)" -"$BGIT" init --noninteractive --repo local-add "$dir" --profile "$GCP_PROFILE" >/dev/null -init_local_git_identity "$dir" +init_output="$(init_bgit_repo gcp local-add)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" printf 'hello\n' > "$dir/README.md" out="$(run_in "$dir" status)" assert_contains "$out" "Untracked files" diff --git a/testsuite/local/branch_checkout_merge.sh b/testsuite/local/branch_checkout_merge.sh index 9da7cff..1cc8fc7 100755 --- a/testsuite/local/branch_checkout_merge.sh +++ b/testsuite/local/branch_checkout_merge.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash source "$(dirname "$0")/../lib/testlib.sh" -dir="$(new_workdir local branch)" -"$BGIT" init --noninteractive --repo local-branch "$dir" --profile "$GCP_PROFILE" >/dev/null -init_local_git_identity "$dir" +init_output="$(init_bgit_repo gcp local-branch)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" commit_file "$dir" README.md base "base" run_in "$dir" checkout -b feature >/dev/null commit_file "$dir" feature.txt feature "feature" diff --git a/testsuite/local/commit.sh b/testsuite/local/commit.sh index 4d858bd..01d7d43 100755 --- a/testsuite/local/commit.sh +++ b/testsuite/local/commit.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash source "$(dirname "$0")/../lib/testlib.sh" -dir="$(new_workdir local commit)" -"$BGIT" init --noninteractive --repo local-commit "$dir" --profile "$GCP_PROFILE" >/dev/null -init_local_git_identity "$dir" +init_output="$(init_bgit_repo gcp local-commit)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" printf 'commit\n' > "$dir/README.md" run_in "$dir" add README.md >/dev/null out="$(run_in "$dir" commit -m "local commit")" diff --git a/testsuite/local/diff_log_show_status.sh b/testsuite/local/diff_log_show_status.sh index cc1ea61..169b5ad 100755 --- a/testsuite/local/diff_log_show_status.sh +++ b/testsuite/local/diff_log_show_status.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash source "$(dirname "$0")/../lib/testlib.sh" -dir="$(new_workdir local inspect)" -"$BGIT" init --noninteractive --repo local-inspect "$dir" --profile "$GCP_PROFILE" >/dev/null -init_local_git_identity "$dir" +init_output="$(init_bgit_repo gcp local-inspect)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" commit_file "$dir" README.md one "one" printf 'two\n' >> "$dir/README.md" out="$(run_in "$dir" diff)" diff --git a/testsuite/local/porcelain_misc.sh b/testsuite/local/porcelain_misc.sh index 474fd6e..68382aa 100755 --- a/testsuite/local/porcelain_misc.sh +++ b/testsuite/local/porcelain_misc.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash source "$(dirname "$0")/../lib/testlib.sh" -dir="$(new_workdir local porcelain)" -"$BGIT" init --noninteractive --repo local-porcelain "$dir" --profile "$GCP_PROFILE" >/dev/null -init_local_git_identity "$dir" +init_output="$(init_bgit_repo gcp local-porcelain)" +dir="$(printf "%s\n" "$init_output" | sed -n "1p")" commit_file "$dir" README.md "alpha" "initial" run_in "$dir" tag v1 >/dev/null assert_contains "$(run_in "$dir" tag)" "v1" diff --git a/web.go b/web.go index 4eb7922..47cd9db 100644 --- a/web.go +++ b/web.go @@ -435,6 +435,8 @@ func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleAPIIssues(ctx, w, r) case route == "api/settings": s.handleAPISettings(ctx, w, r) + case route == "api/settings-fragment": + s.handleAPISettingsFragment(ctx, w, r) case route == "api/blob": srv.handleAPIBlob(ctx, w, r) case strings.HasPrefix(route, "api/commit/"): @@ -453,6 +455,8 @@ func (s *webServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleIssue(ctx, w, r, strings.TrimPrefix(route, "issues/")) case route == "settings": s.handleSettings(ctx, w, r) + case route == "admin": + http.Redirect(w, r, "/settings", http.StatusFound) case route == "archive.zip": srv.handleArchiveZip(ctx, w, r) case strings.HasPrefix(route, "commit/"): @@ -735,6 +739,17 @@ type webSettingsInfo struct { Errors map[string]string `json:"errors,omitempty"` } +type webSettingsCache struct { + UpdatedAt int64 `json:"updated_at"` + Info webSettingsInfo `json:"info"` +} + +type brokerRepoTeamGrant struct { + ID string `json:"id,omitempty"` + TeamID string `json:"team_id,omitempty"` + Role string `json:"role,omitempty"` +} + type brokerRepoInfoRequest struct { Repo brokerRepo `json:"repo"` Description string `json:"description,omitempty"` @@ -743,6 +758,12 @@ type brokerRepoInfoRequest struct { ReadOnly bool `json:"read_only,omitempty"` IssuesEnabled bool `json:"issues_enabled"` Logical string `json:"logical,omitempty"` + TeamID string `json:"team_id,omitempty"` + Name string `json:"name,omitempty"` + User string `json:"user,omitempty"` + Role string `json:"role,omitempty"` + BrokerRole string `json:"broker_role,omitempty"` + PublicKeys []string `json:"public_keys,omitempty"` } type brokerRepoInfoResponse struct { @@ -759,7 +780,30 @@ func (s *webServer) handleAPISettings(ctx context.Context, w http.ResponseWriter http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - s.renderJSON(w, s.settingsInfo(ctx)) + refresh := r.URL.Query().Get("refresh") == "1" + info, _, err := s.cachedSettingsInfo(ctx, refresh) + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + s.renderJSON(w, info) +} + +func (s *webServer) handleAPISettingsFragment(ctx context.Context, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + view := strings.TrimSpace(r.URL.Query().Get("view")) + if view == "" { + view = "settings" + } + info, _, err := s.cachedSettingsInfo(ctx, r.URL.Query().Get("refresh") == "1") + if err != nil { + s.renderJSONError(w, http.StatusBadRequest, err) + return + } + s.renderJSON(w, map[string]any{"html": s.settingsSectionsHTML(info, view), "settings": info}) } func (s *webServer) handleAPIIssues(ctx context.Context, w http.ResponseWriter, r *http.Request) { @@ -845,6 +889,73 @@ func (s *webServer) settingsInfo(ctx context.Context) webSettingsInfo { return info } +func (s *webServer) settingsSeedInfo() webSettingsInfo { + return webSettingsInfo{ + Repo: repoForBroker(s.cfg), + Title: s.title, + BrokerURL: s.cfg.brokerURL, + Provider: s.cfg.provider, + Region: firstNonEmpty(s.cfg.region, globalConfigRegionForBrokerURL(s.cfg.brokerURL)), + DefaultBranch: defaultBranch, + Visibility: "private", + IssuesEnabled: true, + } +} + +func (s *webServer) settingsCachePath() string { + if s.localGitDir == "" { + return "" + } + return filepath.Join(s.localGitDir, "bucketgit", "cache", "settings.json") +} + +func (s *webServer) readSettingsCache() (webSettingsInfo, error) { + path := s.settingsCachePath() + if path == "" { + return webSettingsInfo{}, fs.ErrNotExist + } + data, err := os.ReadFile(path) + if err != nil { + return webSettingsInfo{}, err + } + var cache webSettingsCache + if err := json.Unmarshal(data, &cache); err != nil { + return webSettingsInfo{}, err + } + return cache.Info, nil +} + +func (s *webServer) writeSettingsCache(info webSettingsInfo) error { + path := s.settingsCachePath() + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(webSettingsCache{UpdatedAt: time.Now().Unix(), Info: info}, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return nil + } + return os.WriteFile(path, data, 0o644) +} + +func (s *webServer) cachedSettingsInfo(ctx context.Context, refresh bool) (webSettingsInfo, bool, error) { + if !refresh { + if info, err := s.readSettingsCache(); err == nil { + return info, true, nil + } + return s.settingsSeedInfo(), false, nil + } + info := s.settingsInfo(ctx) + _ = s.writeSettingsCache(info) + return info, false, nil +} + func globalConfigRegionForBrokerURL(brokerURL string) string { want := normalizeBrokerURLForCompare(brokerURL) if want == "" { @@ -1360,7 +1471,6 @@ func (s *webServer) handleAPIActionSettings(ctx context.Context, w http.Response Logical string `json:"logical"` User string `json:"user"` Role string `json:"role"` - PublicKey string `json:"public_key"` Key string `json:"key"` Ref string `json:"ref"` RequirePR bool `json:"require_pr"` @@ -2109,7 +2219,7 @@ func (s *webServer) handleIssue(ctx context.Context, w http.ResponseWriter, r *h } func (s *webServer) handleSettings(ctx context.Context, w http.ResponseWriter, r *http.Request) { - info := s.settingsInfo(ctx) + info, cached, _ := s.cachedSettingsInfo(ctx, false) ref := branchRef(firstNonEmpty(s.cfg.branch, defaultBranch)) var body strings.Builder body.WriteString(`
`) @@ -2122,20 +2232,35 @@ func (s *webServer) handleSettings(ctx context.Context, w http.ResponseWriter, r s.renderPage(w, webPageTitle(s.title, "settings"), body.String()) return } - body.WriteString(s.settingsAboutHTML(info)) - body.WriteString(s.settingsAccessHTML(info)) - body.WriteString(s.settingsBranchesHTML(info)) - body.WriteString(s.settingsPullRequestsHTML(info)) - body.WriteString(s.settingsDangerHTML(info)) + body.WriteString(`
`) + body.WriteString(s.settingsInitialSectionsHTML(info, "settings", cached)) + body.WriteString(`
`) + body.WriteString(`
`) + s.renderPage(w, webPageTitle(s.title, "settings"), body.String()) +} + +func (s *webServer) settingsInitialSectionsHTML(info webSettingsInfo, view string, cached bool) string { + if cached { + return s.settingsSectionsHTML(info, view) + } + return `

Loading broker data

Repository administration data is loading in the background.

` +} + +func (s *webServer) settingsSectionsHTML(info webSettingsInfo, view string) string { + var b strings.Builder + b.WriteString(s.settingsAboutHTML(info)) + b.WriteString(s.settingsAccessHTML(info)) + b.WriteString(s.settingsBranchesHTML(info)) + b.WriteString(s.settingsPullRequestsHTML(info)) + b.WriteString(s.settingsDangerHTML(info)) if len(info.Errors) > 0 { - body.WriteString(`

Unavailable sections

`) + b.WriteString(`

Unavailable sections

`) for name, message := range info.Errors { - body.WriteString(`
` + html.EscapeString(name) + ` ` + html.EscapeString(message) + `
`) + b.WriteString(`
` + html.EscapeString(name) + ` ` + html.EscapeString(message) + `
`) } - body.WriteString(`
`) + b.WriteString(`
`) } - body.WriteString(``) - s.renderPage(w, webPageTitle(s.title, "settings"), body.String()) + return b.String() } func (s *webServer) settingsAboutHTML(info webSettingsInfo) string { @@ -2208,7 +2333,7 @@ func (s *webServer) settingsAccessHTML(info webSettingsInfo) string { b.WriteString(``) b.WriteString(`
`) b.WriteString(`

Invite member

`) diff --git a/www/app.css b/www/app.css index 6a78ef9..50563f4 100644 --- a/www/app.css +++ b/www/app.css @@ -512,7 +512,7 @@ h2 { } .repo-toolbar { position: relative; - z-index: 0; + z-index: 40; display: grid; grid-template-columns: minmax(0, auto) minmax(260px, 1fr); align-items: center; @@ -608,6 +608,7 @@ h2 { } .code-menu { position: relative; + z-index: 70; flex: 0 0 auto; } .code-menu-button { @@ -629,7 +630,7 @@ h2 { position: absolute; right: 0; top: calc(100% + 6px); - z-index: 20; + z-index: 80; width: min(440px, calc(100vw - 32px)); border: 1px solid var(--border); border-radius: 8px; @@ -1918,6 +1919,12 @@ h2 { padding: 16px; border-bottom: 1px solid var(--repo-border); } +.settings-loading p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} .settings-section:last-child { border-bottom: 0; } diff --git a/www/app.js b/www/app.js index edff5fa..063f44a 100644 --- a/www/app.js +++ b/www/app.js @@ -25,6 +25,13 @@ document.addEventListener('click', function (event) { return; } + const disabledCapabilityLink = event.target.closest('a.is-capability-disabled'); + if (disabledCapabilityLink) { + event.preventDefault(); + setSyncStatus(disabledCapabilityLink.getAttribute('title') || 'Your current broker role does not allow this action.', 'is-stale'); + return; + } + const commitRow = event.target.closest('[data-commit-href]'); if (commitRow && !event.target.closest('a, button, input, textarea, select, label, .commit-inline-detail')) { event.preventDefault(); @@ -259,6 +266,7 @@ document.addEventListener('DOMContentLoaded', function () { connectBgitEvents(); refreshWhoamiState(); hydrateRefs(); + refreshSettingsSections(); refreshRemoteState({refreshPullRequests: false}); window.setInterval(function () { refreshRemoteState({refreshPullRequests: true}); }, 30000); }); @@ -506,6 +514,24 @@ async function refreshWhoamiState() { } catch (_) {} } +async function refreshSettingsSections() { + const root = document.querySelector('[data-settings-sections]'); + if (!root) return; + const view = root.getAttribute('data-settings-view') || 'settings'; + root.classList.add('is-refreshing'); + try { + const data = await fetchJSON('/api/settings-fragment?refresh=1&view=' + encodeURIComponent(view)); + if (data && typeof data.html === 'string') { + root.innerHTML = data.html; + applyCapabilityUI(); + } + } catch (err) { + root.innerHTML = '

Broker data unavailable

' + escapeHTML(compactError(err)) + '
'; + } finally { + root.classList.remove('is-refreshing'); + } +} + function setWhoamiState(value) { currentWhoami = value || null; document.documentElement.dataset.bgitRole = currentWhoami && currentWhoami.role ? currentWhoami.role : ''; @@ -518,6 +544,12 @@ function hasCapability(name) { return currentWhoami.capabilities[name] === true; } +function hasAnyCapability(names) { + const parts = String(names || '').split(/\s+/).filter(Boolean); + if (parts.length === 0) return true; + return parts.some(hasCapability); +} + function applyCapabilityUI() { for (const el of document.querySelectorAll('[data-capability]')) { const allowed = hasCapability(el.getAttribute('data-capability') || ''); @@ -531,6 +563,18 @@ function applyCapabilityUI() { for (const control of el.querySelectorAll('button, input, select, textarea')) control.disabled = !allowed; } } + for (const el of document.querySelectorAll('[data-capability-any]')) { + const allowed = hasAnyCapability(el.getAttribute('data-capability-any') || ''); + const disabledMessage = 'Your current broker role does not allow this action.'; + if (el.matches('button, input, select, textarea')) { + el.disabled = !allowed; + el.title = allowed ? '' : disabledMessage; + } else { + el.classList.toggle('is-capability-disabled', !allowed); + el.title = allowed ? '' : disabledMessage; + for (const control of el.querySelectorAll('button, input, select, textarea')) control.disabled = !allowed; + } + } } async function fetchJSON(path) {