Onboarding New Engineers with Self-Service Workflows

This how-to applies the scaffolder to people rather than services: a self-service workflow that provisions a new engineer’s group memberships, repository access, and starter tasks on day one, with every grant attributed and auditable. It is part of Service Onboarding Automation within Developer Experience & Self-Service Platforms, and the access grants must conform to your Team Permission Models.

People onboarding needs four things: the scaffolder plus a custom grant action, identity-provider and GitHub tokens, synced User/Group entities to resolve against, and an approver to receive the asynchronous notification.

Prerequisites for engineer onboarding The scaffolder with a custom grant action, IdP and GitHub tokens, synced entities, and an approver enable people onboarding. scaffolder + action IdP + GitHub tokens User/Group synced approver user Onboarding runs attributed grants
IdP grants are organization-specific, so a custom action is the one piece Backstage does not ship out of the box.
  • @backstage/plugin-scaffolder-backend@^1.22.0 with a custom action module for identity-provider grants.
  • ${IDP_API_TOKEN} for the identity provider (group membership) and ${GITHUB_TOKEN} for repository team membership, injected via app-config.yaml.
  • Catalog Group and User entities synced from the identity provider so the workflow can resolve teams.
  • A manager or buddy User to receive the asynchronous approval notification.
  • @backstage/cli@^0.27.0 for validation and dry-run.

Exact Configuration

The workflow provisions the safe grants immediately and pushes anything needing sign-off to an asynchronous notification: capture the intake, grant the team group, add starter repository access, then notify the buddy without blocking the run.

Engineer-onboarding workflow with async approval Intake feeds a group grant and repository access immediately, with a buddy notification off the critical path. Intake user · team · date Grant team group custom action Starter repo access collaborator add notify buddy (async) Productive day one no blocking gate
Keeping approval off the critical path means a new hire is productive on day one while sign-off happens in parallel.

1. Capture the new-hire intake

# templates/onboard-engineer.yaml
# Requires scaffolder.backstage.io/v1beta3 (Backstage >= 1.22.0)
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: onboard-engineer
  title: Onboard a New Engineer
spec:
  owner: group:people-ops
  type: onboarding
  parameters:
    - title: New engineer
      required: [userRef, team, startDate]
      properties:
        userRef:
          title: Engineer
          type: string
          ui:field: EntityPicker
          ui:options: { catalogFilter: { kind: User } }
        team:
          title: Joining team
          type: string
          ui:field: OwnerPicker
          ui:options: { catalogFilter: { kind: Group } }
        startDate: { title: Start date, type: string, format: date }

2. Grant group membership through a custom action

Repository and service-creation actions ship with Backstage; identity-provider grants are organization-specific, so register a custom action (see the backend-plugin patterns in Building Custom Backstage Plugins).

// src/scaffolder/actions/grantGroupMembership.ts
// Requires @backstage/plugin-scaffolder-node >= 0.4.0
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';

export const grantGroupMembership = createTemplateAction<{
  userRef: string;
  team: string;
}>({
  id: 'idp:group:grant',
  schema: {
    input: {
      type: 'object',
      required: ['userRef', 'team'],
      properties: {
        userRef: { type: 'string' },
        team: { type: 'string' },
      },
    },
  },
  async handler(ctx) {
    const { userRef, team } = ctx.input;
    // Token injected at runtime; never hardcode
    const res = await fetch(`${process.env.IDP_API_URL}/groups/${team}/members`, {
      method: 'POST',
      headers: { authorization: `Bearer ${process.env.IDP_API_TOKEN}` },
      body: JSON.stringify({ user: userRef }),
    });
    if (!res.ok) throw new Error(`grant failed: ${res.status}`);
    ctx.logger.info(`granted ${userRef} membership in ${team}`);
  },
});

3. Sequence the workflow with asynchronous approval

Keep human review out of the critical path: provision the safe grants immediately, and emit an approval request for anything that needs sign-off rather than blocking the run.

  steps:
    - id: grant-team
      name: Add to team group
      action: idp:group:grant
      input:
        userRef: ${{ parameters.userRef }}
        team: ${{ parameters.team }}

    - id: starter-repo-access
      name: Grant starter repository access
      action: github:repo:collaborator:add
      input:
        repoUrl: github.com?owner=${GITHUB_ORG}&repo=onboarding-tasks
        username: ${{ parameters.userRef }}
        permission: push

    - id: notify-buddy
      name: Notify onboarding buddy
      action: http:backstage:request
      input:
        method: POST
        path: /api/notifications/onboarding
        body:
          userRef: ${{ parameters.userRef }}
          startDate: ${{ parameters.startDate }}

4. Register the custom action

// src/scaffolder/index.ts
// Requires @backstage/plugin-scaffolder-backend >= 1.22.0
import { grantGroupMembership } from './actions/grantGroupMembership';
export const additionalActions = [grantGroupMembership()];

Validate three things: the template schema-validates, the custom grant action passes its unit test against a mocked IdP, and a dry-run resolves the user and team to real entity refs.

Three engineer-onboarding validation checks Schema validation, a unit test of the grant action, and a dry-run resolving user and team refs. Schema validate 0 errors Action unit test mocked IdP Dry-run refs user: / group:
Unit-testing the grant action against a mock IdP is what lets you assert the 409 Conflict path is treated as success.
# Requires @backstage/cli >= 0.27.0
set -euo pipefail

# 1. Validate the template
npx @backstage/cli catalog validate --path ./templates/onboard-engineer.yaml
# expected: "Validated 1 entity ... 0 errors"

# 2. Unit-test the custom action against a mocked IdP
npm run test -- grantGroupMembership
# expected: "PASS  ... grants membership"

# 3. Dry-run resolves the user and team to entity refs
yq '.spec.steps[0].input.userRef' ./dry-run-output/run.yaml | grep -qE '^user:'
# expected: exit 0

The onboarding pitfalls split into timing issues (sync lag, duplicate grants, blocking approval) and identity issues (no users to pick, missing audit actor). The diagram groups them so the fix is either an idempotency/async change or an identity-plumbing fix.

Engineer-onboarding pitfalls by class Sync lag, duplicate grants, and blocking approval are timing issues; no users and missing audit actor are identity issues. Timing sync lag dup / blocking → poll + 409-ok + async Identity no users to pick audit actor missing → sync users + forward id
Forwarding the requesting user's identity into the grant action is what keeps every access change attributable in the audit log.
Symptom Root Cause Resolution
Grant succeeds but membership absent Identity-provider sync lags the grant Trigger a catalog refresh after the grant, or poll the IdP before marking the step done
Workflow blocks on approval Human gate placed in the critical path Move approval to the asynchronous notify step; provision safe grants immediately
Re-running duplicates memberships Action not idempotent Treat 409 Conflict from the IdP as success in the action handler
EntityPicker shows no users User entities not synced from the IdP Verify the org-data ingestion is running and User kind is allowed
Audit log missing the actor Action ran under a shared token Forward the requesting user’s identity into the action and log it on every grant

Frequently Asked Questions

Should engineer onboarding live in the same portal as service onboarding?

Yes — sharing one scaffolder gives both flows a single audit trail, a consistent RBAC model, and one place developers and people-ops look. The templates differ (people produce access grants, services produce repositories) but the execution surface, permission checks, and observability are identical, which is exactly the consistency you want for anything that grants access.

How do I keep access grants least-privilege during onboarding?

Grant only the memberships the joining team’s role requires and let downstream RBAC derive everything else from group membership, rather than enumerating per-repository permissions in the workflow. This keeps the onboarding template stable as projects change and concentrates authorization logic in your permission policy where it belongs.