Skip to content

Team Mapping and Sync

Shoehorn synchronizes team memberships from identity providers (IdPs), Kubernetes clusters, and GitHub. This keeps team ownership up to date without manual maintenance.

Teams and ownership data flow into Shoehorn from multiple sources:

SourceMechanismWhat Gets Synced
Identity ProviderJWT group claims on loginUser-to-team membership
OrgData SyncPeriodic API pullUsers and groups from Okta, Zitadel
KubernetesAgent namespace labelsWorkload-to-team ownership
GitHubCrawler topic discoveryRepository-to-team ownership
  1. Configure your IdP to include a groups claim in the JWT token
  2. Create group mappings linking IdP groups to Shoehorn teams
  3. When a user logs in, Shoehorn reads their groups and updates team memberships
ProviderGroup ClaimSetup
Zitadelgroups (included by default)Zitadel setup
Oktagroups (requires claim configuration)Okta setup
Keycloakgroups (realm groups)Identity Providers

Map an IdP group to a Shoehorn team via the API:

Terminal window
curl -X POST https://shoehorn.example.com/api/v1/admin/teams/<team-id>/group-mappings \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"group_id": "engineering-platform",
"idp_provider": "zitadel"
}'

Or via the UI:

  1. Navigate to Organization > Teams
  2. Select a team
  3. Go to the Group Mappings tab
  4. Click Add Mapping
  5. Enter the IdP group name and provider
  6. Click Save
  • Each IdP group maps to exactly one Shoehorn team
  • A team can have multiple group mappings (from one or more providers)
  • Mappings are one-way: IdP group -> Shoehorn team
  • Changes to IdP group membership are reflected on next user login
  • All changes are recorded in the team audit log

OrgData sync periodically pulls users and groups from identity providers without waiting for individual logins.

auth:
orgdata:
enabled: true
providers: ["okta", "zitadel"] # Providers to sync from
primaryProvider: "zitadel" # Wins in conflicts
oktaApiTokenSecret:
name: integration-credentials
key: okta_api_token

When syncing from multiple providers:

  • Users: Deduplicated by email address
  • Teams: Deduplicated by name
  • Conflicts: The primary provider wins
ProviderSecret KeyHow to Generate
Zitadelservice-user-patZitadel console > Service Users > PAT
Oktaokta_api_tokenOkta admin > Security > API > Tokens

The K8s agent automatically infers team ownership for discovered workloads.

  1. Workload annotation shoehorn.dev/team (highest priority)
  2. Workload annotation shoehorn.dev/owner
  3. Workload label owner
  4. Namespace label shoehorn.dev/team
  5. Namespace name pattern (e.g., payments-prod -> payments)
  6. Default: unassigned

Label namespaces to assign all workloads in that namespace to a team:

Terminal window
kubectl label namespace payments shoehorn.dev/team=payments-team

All workloads in the payments namespace are then owned by payments-team.

Override the namespace-level team for individual workloads:

apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-processor
annotations:
shoehorn.dev/team: payments-team

The crawler assigns ownership to repositories based on GitHub topics.

Add a topic following the pattern team-<team-slug>:

GitHub TopicMaps to Team
team-platform-engineeringplatform-engineering
team-paymentspayments
team-data-engineeringdata-engineering

GitHub topics have the lowest priority for ownership:

  1. Manifest owner field (highest)
  2. Kubernetes annotation shoehorn.dev/owner
  3. GitHub topic team-* (lowest)

See GitHub Topics for details.

When multiple sources provide ownership for the same entity:

PrioritySourceExample
1 (highest)Manifest owner fieldowner: [{type: team, id: payments}]
2Kubernetes annotationshoehorn.dev/team: payments
3Namespace labelshoehorn.dev/team: payments on namespace
4GitHub topicteam-payments topic on repository
5 (lowest)Defaultunassigned

Teams can also be managed as infrastructure-as-code using the Terraform provider:

resource "shoehorn_team" "platform" {
name = "Platform Engineering"
slug = "platform-engineering"
description = "Manages shared infrastructure and platform services"
metadata = jsonencode({
cost_center = "CC-1234"
slack_channel = "#platform-eng"
oncall_rotation = "platform-primary"
})
}