diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 15956630..0fc240f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: 1.22.x + go-version: 1.23.x - name: Checkout code uses: actions/checkout@v3 - name: Run linters @@ -18,7 +18,7 @@ jobs: go-test: strategy: matrix: - go-version: [1.22.x] + go-version: [1.23.x] platform: [ubuntu-latest] runs-on: ${{ matrix.platform }} steps: @@ -35,4 +35,85 @@ jobs: if: always() uses: guyarb/golang-test-annotations@v0.5.1 with: - test-results: test.json \ No newline at end of file + test-results: test.json + + test: + runs-on: ubuntu-latest + env: + BATON_LOG_LEVEL: debug + ONE_PASSWORD_EMAIL: ${{ secrets.EMAIL }} + ONE_PASSWORD_PASSWORD: ${{ secrets.PASSWORD }} + ONE_PASSWORD_SECRET_KEY: ${{ secrets.SECRET_KEY }} + ONE_PASSWORD_ACCOUNT_ADDRESS: ${{ secrets.ADDRESS }} + GROUP_ENTITLEMENT: ${{ vars.GROUP_ENTITLEMENT }} + GROUP_GRANT: ${{ vars.GROUP_GRANT }} + PRINCIPAL: ${{ vars.PRINCIPAL }} + VAULT_ENTITLEMENT: ${{ vars.VAULT_ENTITLEMENT }} + VAULT_GRANT: ${{ vars.VAULT_GRANT }} + PRINCIPAL_TYPE: ${{ vars.PRINCIPAL_TYPE }} + + steps: + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.23.x + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install 1Password CLI + run: | + curl -sS https://downloads.1password.com/linux/keys/1password.asc | sudo gpg --dearmor -o /usr/share/keyrings/1password-archive-keyring.gpg + echo 'deb [signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/amd64 stable main' | sudo tee /etc/apt/sources.list.d/1password.list + sudo apt update + sudo apt install -y 1password-cli + op --version + + - name: Build baton-1password + run: go build ./cmd/baton-1password + + - name: Run baton-1password (generate sync.c1z) + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} + + - name: Install baton + run: | + chmod +x ./scripts/get-baton.sh + ./scripts/get-baton.sh + mv baton /usr/local/bin/ + + - name: Grant entitlement Group + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} --grant-entitlement=${{ env.GROUP_ENTITLEMENT }} --grant-principal=${{env.PRINCIPAL}} --grant-principal-type=${{ env.PRINCIPAL_TYPE }} + + - name: Re-sync the data from 1password + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} + + - name: Check grants was granted + run: baton grants --entitlement="${{ env.GROUP_ENTITLEMENT }}" --output-format=json | jq --exit-status '.grants[].principal.id.resource == "${{ env.PRINCIPAL }}"' | grep true + + - name: Revoke grants Group + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} --revoke-grant="${{env.GROUP_GRANT}}" + + - name: Re-sync the data from 1password + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} + + - name: Check grant was revoked + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} && baton grants --entitlement="${{ env.GROUP_ENTITLEMENT }}" --output-format=json | jq --exit-status 'if .grants then .grants[]?.principal.id.resource != "${{ env.PRINCIPAL }}" else . end' + + - name: Grant entitlement Vault + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} --grant-entitlement=${{ env.VAULT_ENTITLEMENT }} --grant-principal=${{ env.PRINCIPAL }} --grant-principal-type=${{ env.PRINCIPAL_TYPE }} + + - name: Re-sync the data from 1password + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} + + - name: Check grants was granted + run: baton grants --entitlement="${{ env.VAULT_ENTITLEMENT }}" --output-format=json | jq --exit-status '.grants[].principal.id.resource == "${{ env.PRINCIPAL }}"' | grep true + + - name: Revoke grants Vault + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} --revoke-grant="${{env.VAULT_GRANT}}" + + - name: Re-sync the data from 1password + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} + + - name: Check grant was revoked + run: ./baton-1password --password=${{ env.ONE_PASSWORD_PASSWORD }} --email=${{ env.ONE_PASSWORD_EMAIL }} --secret-key=${{ env.ONE_PASSWORD_SECRET_KEY }} --address=${{ env.ONE_PASSWORD_ACCOUNT_ADDRESS }} && baton grants --entitlement="${{ env.VAULT_ENTITLEMENT }}" --output-format=json | jq --exit-status 'if .grants then .grants[]?.principal.id.resource != "${{ env.PRINCIPAL }}" else . end' + diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 9710e4f8..114a7c65 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -10,7 +10,7 @@ jobs: - name: Install Go uses: actions/setup-go@v4 with: - go-version: 1.22.x + go-version: 1.23.x - name: Checkout code uses: actions/checkout@v3 - name: Run linters @@ -21,7 +21,7 @@ jobs: go-test: strategy: matrix: - go-version: [ 1.22.x ] + go-version: [ 1.23.x ] platform: [ ubuntu-latest ] runs-on: ${{ matrix.platform }} steps: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3aef76c7..db9b66df 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: 1.22.x + go-version: 1.23.x - name: Set up Gon run: brew tap conductorone/gon && brew install conductorone/gon/gon - name: Import Keychain Certs @@ -43,7 +43,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: 1.22.x + go-version: 1.23.x - name: Docker Login uses: docker/login-action@v1 with: diff --git a/README.md b/README.md index f30eb947..0558e701 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,24 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more about the 3. Installed 1Password [CLI Tool](https://developer.1password.com/docs/cli) on your local machine. For first time install please refer to the [Install](https://developer.1password.com/docs/cli/get-started/#install) chapter. It is not neccessary to do any other steps as the `baton-1password` will take care of creating an account and signing in. If you already have the CLI tool installed but need to upgrade it to the latest version please refer to [this](https://developer.1password.com/docs/cli/upgrade/) article. + IMPORTANT NOTE: If a service account is used, its token must be stored in a local environment variable (OP_SERVICE_ACCOUNT_TOKEN) in order for the 1Password CLI to authenticate properly: +``` + OP_SERVICE_ACCOUNT_TOKEN=your-service-account-token +``` + +## Connector capabilities + +- The connector can be authenticated using either a regular user account or a 1Password service account. + +- Sync Users, projects, groups and vaults. + +- Supports Groups provision + +- Support Vaults provision + IMPORTANT NOTE: Vault provisioning is limited with a service account: + When using a service account to run the connector, vault provisioning is limited by 1Password. Specifically, only vaults that were created by the same service account can be modified. + Vaults that were created by other users or service accounts cannot be granted or revoked permissions using a service account. + ## brew ``` diff --git a/pkg/connector/group.go b/pkg/connector/group.go index e27e5af5..22331f0c 100644 --- a/pkg/connector/group.go +++ b/pkg/connector/group.go @@ -122,18 +122,16 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, _ } func (o *groupResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { - l := ctxzap.Extract(ctx) - if principal.Id.ResourceType != resourceTypeUser.Id { - l.Warn( - "baton-1password: only users can be granted group membership", - zap.String("principal_type", principal.Id.ResourceType), - zap.String("principal_id", principal.Id.Resource), - ) return nil, fmt.Errorf("baton-1password: only users can be granted group membership") } - err := o.cli.AddUserToGroup(ctx, entitlement.Resource.Id.Resource, entitlement.Slug, principal.Id.Resource) + role, err := extractRoleFromEntitlementID(entitlement.Id) + if err != nil { + return nil, fmt.Errorf("could not extract role: %w", err) + } + + err = o.cli.AddUserToGroup(ctx, entitlement.Resource.Id.Resource, role, principal.Id.Resource) if err != nil { return nil, fmt.Errorf("baton-1password: failed adding user to group") diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 36b3288e..84f6f918 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -2,10 +2,12 @@ package connector import ( "fmt" + "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + mapset "github.com/deckarep/golang-set/v2" ) // Populate entitlement options for a 1Password resource. @@ -23,3 +25,76 @@ func annotationsForUserResourceType() annotations.Annotations { annos.Update(&v2.SkipEntitlementsAndGrants{}) return annos } + +func extractRoleFromEntitlementID(entitlementID string) (string, error) { + parts := strings.Split(entitlementID, ":") + if len(parts) != 3 { + return "", fmt.Errorf("invalid entitlement ID: %s", entitlementID) + } + role := parts[2] + // Formatting to replace spaces with _ + role = strings.ReplaceAll(role, " ", "_") + return role, nil +} + +func uniqueStrings(input []string) []string { + set := mapset.NewSet[string]() + for _, s := range input { + set.Add(s) + } + return set.ToSlice() +} + +func getPermissionsForGrantRevoke(permissionGrant string, accountType string, isRevoke bool) []string { + if isRevoke { + return getRevokePermissions(permissionGrant, accountType) + } + return getGrantPermissions(permissionGrant, accountType) +} + +func getRevokePermissions(permissionGrant string, accountType string) []string { + switch permissionGrant { + case memberEntitlement: + if accountType == businessAccountType { + return uniqueStrings(append( + expandPermissionsForRevoke("view_items"), + "manage_vault", + )) + } + return uniqueStrings(append( + expandPermissionsForRevoke("allow_viewing"), + "allow_managing", + )) + + case managerEntitlement: + if accountType == businessAccountType { + return []string{"manage_vault"} + } + return []string{"allow_managing"} + + default: + return expandPermissionsForRevoke(permissionGrant) + } +} + +func getGrantPermissions(permissionGrant string, accountType string) []string { + switch permissionGrant { + case memberEntitlement: + if accountType == businessAccountType { + return resolveDeps("view_items", reverseDependencyMap, make(map[string]bool)) + } + return expandPermissions("allow_editing") + + case managerEntitlement: + if accountType == businessAccountType { + return uniqueStrings(append( + expandPermissions("view_items"), + "manage_vault", + )) + } + return expandPermissions("allow_managing") + + default: + return expandPermissions(permissionGrant) + } +} diff --git a/pkg/connector/vault.go b/pkg/connector/vault.go index bfb6068a..9dc67709 100644 --- a/pkg/connector/vault.go +++ b/pkg/connector/vault.go @@ -67,14 +67,59 @@ var dependencyMap = map[string][]string{ "export_items": {"view_item_history", "view_and_copy_passwords", "view_items"}, "copy_and_share_items": {"view_item_history", "view_and_copy_passwords", "view_items"}, "print_items": {"view_item_history", "view_and_copy_passwords", "view_items"}, + "allow_editing": {"allow_viewing"}, } -// addPermissionDeps Takes a permission, returns it and its dependencies in a comma-separated string. -func addPermissionDeps(permission string) string { - res := []string{permission} - deps := dependencyMap[permission] - res = append(res, deps...) - return strings.Join(res, ",") +// Used to determine the permissions to revoke when a user's permission is revoked. +var reverseDependencyMap = map[string][]string{ + "view_items": {"create_items", "view_and_copy_passwords", "edit_items", "archive_items", "delete_items", "import_items", + "export_items", "copy_and_share_items", "print_items"}, + "view_and_copy_passwords": {"edit_items", "archive_items", "delete_items", "view_item_history", "export_items", "copy_and_share_items", "print_items"}, + "edit_items": {"archive_items", "delete_items"}, + "view_item_history": {"export_items", "copy_and_share_items", "print_items"}, + "create_items": {"import_items"}, + "allow_viewing": {"allow_editing"}, +} + +// resolveDeps recursively resolves all dependencies of a given permission. +// It traverses the dependency graph using depMap, tracking visited permissions +// with the seen map to avoid cycles and duplicates. +// Returns a list of all dependencies plus the permission itself, in order. +func resolveDeps(permission string, depMap map[string][]string, seen map[string]bool) []string { + if seen[permission] { + return nil + } + seen[permission] = true + + deps := []string{} + for _, dep := range depMap[permission] { + deps = append(deps, resolveDeps(dep, depMap, seen)...) + } + deps = append(deps, permission) + return deps +} + +// expandPermissions returns the full list of permissions required by +// expanding the dependencies of the given permission based on dependencyMap. +// It ensures no duplicates by tracking visited permissions. +func expandPermissions(permission string) []string { + seen := make(map[string]bool) + return resolveDeps(permission, dependencyMap, seen) +} + +// expandPermissionsForRevoke returns the full list of permissions that depend +// on the given permission, based on reverseDependencyMap. This is useful +// for revoking permissions because it identifies all dependent permissions +// that must also be revoked. It returns a unique set of permissions. +func expandPermissionsForRevoke(permission string) []string { + seen := make(map[string]bool) + deps := resolveDeps(permission, reverseDependencyMap, seen) + + unique := mapset.NewSet[string]() + for _, p := range deps { + unique.Add(p) + } + return unique.ToSlice() } const businessAccountType = "BUSINESS" @@ -316,39 +361,32 @@ func (g *vaultResourceType) Grants(ctx context.Context, resource *v2.Resource, p // Grant a user access to a vault. // grants to vaults must be granted and revoked from individual users only when using just-in-time provisioning. // See Revoke limitations for more details. +// If the connector is used through a service account, it can only grant or revoke permissions on those stores that have been created from that service account, otherwise it will return an error. func (g *vaultResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { - l := ctxzap.Extract(ctx) - grantString := entitlement.Id - // Split out and get the permission from the grant string. - p := strings.Split(grantString, ":") - permissionGrant := p[len(p)-1] - // Formatting to replace spaces with _ - permissionGrant = strings.ReplaceAll(permissionGrant, " ", "_") - // add the dependencies to the permission - permission := addPermissionDeps(permissionGrant) - username := principal.DisplayName vaultId := entitlement.Resource.Id.Resource - l.Info("baton-1password: granting vault access", - zap.String("principal_id", principal.Id.Resource), - zap.String("vault_id", vaultId), - zap.String("username", username), - zap.String("permission", permission), - ) + permissionGrant, err := extractRoleFromEntitlementID(entitlement.Id) + if err != nil { + return nil, fmt.Errorf("could not extract role: %w", err) + } + + account, err := g.cli.GetAccount(ctx) + if err != nil { + return nil, fmt.Errorf("could not fetch account: %w", err) + } + + permissionsList := getPermissionsForGrantRevoke(permissionGrant, account.Type, false) + + permissions := strings.Join(permissionsList, ",") + if principal.Id.ResourceType != resourceTypeUser.Id && principal.Id.ResourceType != resourceTypeGroup.Id { - l.Error( - "baton-1password: only users or groups can be granted vault access", - zap.String("principal_type", principal.Id.ResourceType), - zap.String("principal_id", principal.Id.Resource), - ) return nil, fmt.Errorf("baton-1password: only users or groups can be granted vault access") } - err := g.cli.AddUserToVault(ctx, vaultId, username, permission) - + err = g.cli.AddUserToVault(ctx, vaultId, username, permissions) if err != nil { - return nil, fmt.Errorf("baton-1password: failed granting to vault access") + return nil, fmt.Errorf("baton-1password: failed granting to vault access: %w", err) } return nil, nil @@ -358,40 +396,35 @@ func (g *vaultResourceType) Grant(ctx context.Context, principal *v2.Resource, e // This will error out if the principal's grant was inherited via a group membership with permissions to the vault. // 1Password CLI errors with "the accessor doesn't have any permissions" if the grant is inherited from a group. // Avoid mixing group and individual grants to vaults when using just-in-time provisioning. +// If the connector is used through a service account, it can only grant or revoke permissions on those stores that have been created from that service account, otherwise it will return an error. func (g *vaultResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { - l := ctxzap.Extract(ctx) entitlement := grant.Entitlement - grantString := entitlement.Id - // Split out and get the permission from the grant string. - p := strings.Split(grantString, ":") - permissionGrant := p[len(p)-1] - // Formatting to replace spaces with _ - permissionGrant = strings.ReplaceAll(permissionGrant, " ", "_") - // add the dependencies to the permission - permission := addPermissionDeps(permissionGrant) + + permissionGrant, err := extractRoleFromEntitlementID(entitlement.Id) + if err != nil { + return nil, fmt.Errorf("could not extract role: %w", err) + } + + account, err := g.cli.GetAccount(ctx) + if err != nil { + return nil, fmt.Errorf("could not fetch account: %w", err) + } + + permissionsList := getPermissionsForGrantRevoke(permissionGrant, account.Type, true) + + permissions := strings.Join(permissionsList, ",") principal := grant.Principal username := principal.DisplayName vaultId := entitlement.Resource.Id.Resource - l.Info("baton-1password: revoking vault access", - zap.String("principal_id", principal.Id.Resource), - zap.String("vault_id", vaultId), - zap.String("username", username), - zap.String("permission", permission), - ) + if principal.Id.ResourceType != resourceTypeUser.Id { - l.Error( - "baton-1password: only users can have group membership revoked", - zap.String("principal_type", principal.Id.ResourceType), - zap.String("principal_id", principal.Id.Resource), - ) - return nil, errors.New("baton-1password: only users can have group membership revoked") + return nil, errors.New("baton-1password: only users can have vault access revoked") } - err := g.cli.RemoveUserFromVault(ctx, vaultId, username, permission) - + err = g.cli.RemoveUserFromVault(ctx, vaultId, username, permissions) if err != nil { - return nil, errors.New("baton-1password: failed removing user from group") + return nil, fmt.Errorf("baton-1password: failed removing user from vault: %w", err) } return nil, nil diff --git a/pkg/connector/vault_test.go b/pkg/connector/vault_test.go index 2bd06af8..37356cbd 100644 --- a/pkg/connector/vault_test.go +++ b/pkg/connector/vault_test.go @@ -6,11 +6,20 @@ import ( "github.com/stretchr/testify/require" ) -func TestAddPermissionDeps(t *testing.T) { - perms := addPermissionDeps("create_items") - require.Equal(t, "create_items,view_items", perms) - perms = addPermissionDeps("view_items") - require.Equal(t, "view_items", perms) - perms = addPermissionDeps("") - require.Equal(t, "", perms) +func TestResolveDeps(t *testing.T) { + expected := []string{"view_items", "create_items"} + actual := resolveDeps("create_items", dependencyMap, make(map[string]bool)) + require.Equal(t, expected, actual) + + expected = []string{"view_items", "view_and_copy_passwords", "edit_items"} + actual = resolveDeps("edit_items", dependencyMap, make(map[string]bool)) + require.Equal(t, expected, actual) + + expected = []string{"manage_vault"} + actual = resolveDeps("manage_vault", dependencyMap, make(map[string]bool)) + require.Equal(t, expected, actual) + + expected = []string{""} + actual = resolveDeps("", dependencyMap, make(map[string]bool)) + require.Equal(t, expected, actual) } diff --git a/scripts/get-baton.sh b/scripts/get-baton.sh new file mode 100644 index 00000000..5b4d7552 --- /dev/null +++ b/scripts/get-baton.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euxo pipefail + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +if [ "${ARCH}" = "x86_64" ]; then + ARCH="amd64" +fi + +RELEASES_URL="https://api.github.com/repos/conductorone/baton/releases/latest" +BASE_URL="https://github.com/conductorone/baton/releases/download" + +DOWNLOAD_URL=$(curl "${RELEASES_URL}" | jq --raw-output ".assets[].browser_download_url | match(\"${BASE_URL}/v[.0-9]+/baton-v[.0-9]+-${OS}-${ARCH}.*\"; \"i\").string") + +FILENAME=$(basename ${DOWNLOAD_URL}) + +curl -LO ${DOWNLOAD_URL} +tar xzf ${FILENAME}