Multi-Tenant SaaS Basics: RBAC, Credential Isolation and OAuth Tokens

In BlockNext, users connect their Slack, Google Drive, Telegram and LLM accounts so workflows can act on their behalf. That means the platform holds API keys and OAuth tokens for many organizations in one database. Mistakes in this area are the expensive kind: one missing WHERE clause and organization A runs a workflow with organization B's credentials.

This post walks through the handful of rules that keep tenants apart. None of them are novel. The value is in applying all of them, everywhere, without exceptions.

Roles: small, ordered, static

There are four organization roles, each with a score:

OrganizationOwnerRole  = &Role{Name: "organization:owner",  Score: 100}
OrganizationAdminRole  = &Role{Name: "organization:admin",  Score: 75}
OrganizationEditorRole = &Role{Name: "organization:editor", Score: 50}
OrganizationViewerRole = &Role{Name: "organization:viewer", Score: 25}

Each role maps to a fixed set of permission codes, defined in code rather than in the database:

OrganizationRolePermissions = map[string]map[string]struct{}{
	OrganizationOwnerRole.Name: {
		CreateOrganizationCredentialsPermission.Code: {},
		ReadOrganizationCredentialsPermission.Code:   {},
		UpdateOrganizationCredentialsPermission.Code: {},
		DeleteOrganizationCredentialsPermission.Code: {},

		TriggerTaskPermission.Code: {},
		CancelTaskPermission.Code:  {},
		RetryTaskPermission.Code:   {},
		// ...
	},
	// ...admin, editor, viewer
}

Two decisions behind this:

Keeping roles static, rather than letting customers define their own, is a deliberate limit. Custom roles are a real feature with real cost, and four roles cover most teams.

Every query is scoped by owner

The most important rule is also the most boring. A credential isn't fetched by ID. It's fetched by ID and owner:

SELECT ...
FROM credentials
WHERE id = $1
  AND owner_type = $2
  AND owner_id = $3

If someone sends the ID of another tenant's credential, the query finds nothing, and the API returns "not found", not "forbidden". That matters too. "Forbidden" would confirm that the ID exists.

The owner type and ID come from the authenticated request, never from the request body. Because modules can only reach credentials through the credentials module's service, there's exactly one place where that scoping has to be right.

Encrypted at rest, decrypted at the last moment

Credential data is encrypted with AES-256-GCM before it's stored. The value is marshalled to JSON, sealed with a random nonce, and the nonce is stored in front of the ciphertext:

func (s *secretManager) Encrypt(value any) (string, error) {
	jsonData, err := json.Marshal(value)
	if err != nil {
		return "", err
	}

	block, err := aes.NewCipher(s.secretKey) // 32-byte key: AES-256
	if err != nil {
		return "", err
	}

	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}

	nonce := make([]byte, gcm.NonceSize())
	if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
		return "", err
	}

	ciphertext := gcm.Seal(nonce, nonce, jsonData, nil) // nonce is prepended
	return base64.Encode(ciphertext), nil
}

GCM gives integrity as well as secrecy. A modified ciphertext fails to decrypt instead of producing garbage that might get used. The 32-byte key comes from the environment, and constructing the secret manager fails if the key isn't valid hex or isn't exactly 32 bytes.

Decryption is the more interesting rule. No HTTP endpoint ever returns a decrypted secret. Plain text exists in exactly one situation: when a node is about to run, the task runner asks the credentials module for the decrypted values of that one credential, for that one owner.

Masked on read, safe to send back

Users still need to edit credentials, and an edit form wants to show the current values. So when a single credential is read, every field marked write-only in its schema, such as a client secret, is replaced with a mask value. For OAuth credentials the whole token object collapses to a simple "connected" flag.

The update side is designed for that. When a form is saved, any field that still contains the mask value is skipped, and the stored secret stays as it was. The UI can send the masked form straight back, and only the fields the user actually changed are overwritten. No special "keep existing value" flag, and no way to accidentally wipe a secret by saving an untouched form.

OAuth tokens: refresh once, not twice

OAuth access tokens expire, and many providers rotate the refresh token on each use. The old one stops working the moment a new one is issued. That creates a nasty race. Two workflow runs start at the same time, both see an expiring token, and both refresh it. The first succeeds and gets a new refresh token. The second uses the old refresh token, which is now invalid, and the credential is broken until the user reconnects.

BlockNext refreshes tokens just before a node runs, and only when the token expires within five minutes. The refresh is single-flight per credential:

The lock lives behind the same cache interface as everything else: in memory for a single instance, in Redis when there are several. If a refresh fails with invalid_grant, the run fails with a clear "needs re-authentication" error. A stale token is never silently used.

The OAuth connect flow itself uses PKCE with S256 and a single-use state value with a short TTL. Provider endpoints, like the authorization and token URLs, come from server-side credential schemas, never from the client. Otherwise a crafted request could send a user's authorization code to an endpoint of the attacker's choosing.

The checklist

← All posts