notion-enterprise-rbac

Configure Notion enterprise access control with OAuth, workspace permissions, and audit logging. Use when implementing OAuth public integrations, managing multi-workspace access, or building permission-aware Notion applications. Trigger with phrases like "notion SSO", "notion RBAC", "notion enterprise", "notion OAuth", "notion permissions", "notion multi-workspace".

1,868 stars

Best use case

notion-enterprise-rbac is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Configure Notion enterprise access control with OAuth, workspace permissions, and audit logging. Use when implementing OAuth public integrations, managing multi-workspace access, or building permission-aware Notion applications. Trigger with phrases like "notion SSO", "notion RBAC", "notion enterprise", "notion OAuth", "notion permissions", "notion multi-workspace".

Teams using notion-enterprise-rbac should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/notion-enterprise-rbac/SKILL.md --create-dirs "https://raw.githubusercontent.com/jeremylongshore/claude-code-plugins-plus-skills/main/plugins/saas-packs/notion-pack/skills/notion-enterprise-rbac/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/notion-enterprise-rbac/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How notion-enterprise-rbac Compares

Feature / Agentnotion-enterprise-rbacStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Configure Notion enterprise access control with OAuth, workspace permissions, and audit logging. Use when implementing OAuth public integrations, managing multi-workspace access, or building permission-aware Notion applications. Trigger with phrases like "notion SSO", "notion RBAC", "notion enterprise", "notion OAuth", "notion permissions", "notion multi-workspace".

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

Related Guides

SKILL.md Source

# Notion Enterprise RBAC

## Overview

Implement enterprise-grade access control for Notion integrations. This covers the full OAuth 2.0 authorization flow for public integrations (multi-tenant), per-workspace token storage with encryption at rest, Notion's page-level permission model and how to handle `ObjectNotFound` vs `RestrictedResource`, an application-level role system (admin/editor/viewer) layered on top of Notion's permissions, comprehensive audit logging to a Notion database, and workspace deauthorization cleanup.

## Prerequisites

- Notion public integration created at https://www.notion.so/my-integrations (for OAuth)
- `@notionhq/client` v2+ installed (`npm install @notionhq/client`)
- Python alternative: `notion-client` (`pip install notion-client`)
- Database for storing per-workspace tokens (PostgreSQL, DynamoDB, etc.)
- HTTPS endpoint for OAuth callback (required by Notion)

## Instructions

### Step 1: OAuth 2.0 Authorization Flow

Notion uses OAuth 2.0 for public integrations to access external workspaces:

```typescript
import { Client } from '@notionhq/client';
import crypto from 'crypto';

// Step 1: Build the authorization URL
function getAuthorizationUrl(state: string): string {
  const params = new URLSearchParams({
    client_id: process.env.NOTION_OAUTH_CLIENT_ID!,
    response_type: 'code',
    owner: 'user',       // 'user' = user-level token, 'workspace' = workspace-level
    redirect_uri: process.env.NOTION_REDIRECT_URI!,
    state,               // CSRF protection — must verify on callback
  });
  return `https://api.notion.com/v1/oauth/authorize?${params}`;
}

// Step 2: Exchange authorization code for access token
async function exchangeCodeForToken(code: string): Promise<{
  access_token: string;
  bot_id: string;
  workspace_id: string;
  workspace_name: string;
  workspace_icon: string | null;
  owner: { type: string; user?: { id: string; name: string } };
  duplicated_template_id: string | null;
}> {
  const credentials = Buffer.from(
    `${process.env.NOTION_OAUTH_CLIENT_ID}:${process.env.NOTION_OAUTH_CLIENT_SECRET}`
  ).toString('base64');

  const response = await fetch('https://api.notion.com/v1/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Basic ${credentials}`,
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.NOTION_REDIRECT_URI,
    }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`OAuth token exchange failed: ${error.error}`);
  }

  return response.json();
}

// Step 3: Create a Client for a specific workspace
function createWorkspaceClient(accessToken: string): Client {
  return new Client({ auth: accessToken, timeoutMs: 30_000 });
}

// Express route handlers
app.get('/auth/notion', (req, res) => {
  const state = crypto.randomUUID();
  req.session.oauthState = state;
  res.redirect(getAuthorizationUrl(state));
});

app.get('/auth/notion/callback', async (req, res) => {
  // Verify CSRF state
  if (req.query.state !== req.session.oauthState) {
    return res.status(403).send('Invalid state — possible CSRF attack');
  }

  if (req.query.error) {
    return res.status(400).send(`Authorization denied: ${req.query.error}`);
  }

  const tokenData = await exchangeCodeForToken(req.query.code as string);
  await storeWorkspaceToken(tokenData);

  res.redirect(`/dashboard?workspace=${encodeURIComponent(tokenData.workspace_name)}`);
});
```

**Python — OAuth flow:**

```python
import base64
import requests
from notion_client import Client

def exchange_code_for_token(code: str) -> dict:
    credentials = base64.b64encode(
        f"{os.environ['NOTION_OAUTH_CLIENT_ID']}:{os.environ['NOTION_OAUTH_CLIENT_SECRET']}".encode()
    ).decode()

    response = requests.post(
        "https://api.notion.com/v1/oauth/token",
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Basic {credentials}",
        },
        json={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": os.environ["NOTION_REDIRECT_URI"],
        },
    )
    response.raise_for_status()
    return response.json()

def create_workspace_client(access_token: str) -> Client:
    return Client(auth=access_token, timeout_ms=30_000)
```

### Step 2: Token Storage and Permission-Aware API Calls

**Per-workspace token management:**

```typescript
import { isNotionClientError, APIErrorCode } from '@notionhq/client';

interface WorkspaceToken {
  botId: string;          // Primary key — unique per installation
  workspaceId: string;
  workspaceName: string;
  accessToken: string;    // MUST be encrypted at rest
  ownerUserId: string;
  authorizedAt: Date;
  lastUsedAt: Date;
}

// In production, use a database with encryption (e.g., AWS KMS, column-level encryption)
class TokenStore {
  private tokens = new Map<string, WorkspaceToken>();

  async store(tokenData: any): Promise<void> {
    const entry: WorkspaceToken = {
      botId: tokenData.bot_id,
      workspaceId: tokenData.workspace_id,
      workspaceName: tokenData.workspace_name,
      accessToken: tokenData.access_token,  // Encrypt before storing!
      ownerUserId: tokenData.owner?.user?.id ?? '',
      authorizedAt: new Date(),
      lastUsedAt: new Date(),
    };
    this.tokens.set(entry.botId, entry);
  }

  async getClient(botId: string): Promise<Client> {
    const token = this.tokens.get(botId);
    if (!token) {
      throw new Error(`No token found for bot ${botId}. User needs to re-authorize.`);
    }
    token.lastUsedAt = new Date();
    return new Client({ auth: token.accessToken, timeoutMs: 30_000 });
  }

  async revoke(botId: string): Promise<void> {
    this.tokens.delete(botId);
  }

  async listWorkspaces(): Promise<{ botId: string; name: string; authorizedAt: Date }[]> {
    return Array.from(this.tokens.values()).map(t => ({
      botId: t.botId,
      name: t.workspaceName,
      authorizedAt: t.authorizedAt,
    }));
  }
}

const tokenStore = new TokenStore();
```

**Permission-aware API calls — handle Notion's page-level permissions:**

```typescript
// Notion returns ObjectNotFound for pages not shared with the integration
// This is NOT the same as the page being deleted
async function safePageAccess(notion: Client, pageId: string) {
  try {
    return await notion.pages.retrieve({ page_id: pageId });
  } catch (error) {
    if (!isNotionClientError(error)) throw error;

    switch (error.code) {
      case APIErrorCode.ObjectNotFound:
        // Page exists but is NOT shared with this integration
        // User needs to share it via the "..." menu > Connections
        console.log(`Page ${pageId} not accessible. Ask user to share via Connections.`);
        return null;

      case APIErrorCode.RestrictedResource:
        // Integration lacks the required capability (read/update/insert/delete)
        console.log(`Integration lacks capability for ${pageId}. Check integration settings.`);
        return null;

      case APIErrorCode.Unauthorized:
        // Token was revoked — user needs to re-authorize
        console.log(`Token revoked. User needs to re-authorize.`);
        return null;

      default:
        throw error;
    }
  }
}

// List all pages accessible to the integration (discovers shared content)
async function discoverAccessiblePages(notion: Client): Promise<string[]> {
  const pageIds: string[] = [];
  let cursor: string | undefined;

  do {
    const response = await notion.search({
      filter: { property: 'object', value: 'page' },
      page_size: 100,
      start_cursor: cursor,
    });
    pageIds.push(...response.results.map(r => r.id));
    cursor = response.has_more ? response.next_cursor ?? undefined : undefined;
  } while (cursor);

  return pageIds;
}
```

### Step 3: Application-Level Roles and Audit Logging

**Role-based access control layered on top of Notion permissions:**

```typescript
enum AppRole {
  Admin = 'admin',
  Editor = 'editor',
  Viewer = 'viewer',
}

const ROLE_PERMISSIONS: Record<AppRole, {
  canRead: boolean;
  canWrite: boolean;
  canDelete: boolean;
  canManageIntegration: boolean;
}> = {
  admin:  { canRead: true, canWrite: true, canDelete: true, canManageIntegration: true },
  editor: { canRead: true, canWrite: true, canDelete: false, canManageIntegration: false },
  viewer: { canRead: true, canWrite: false, canDelete: false, canManageIntegration: false },
};

interface AppUser {
  id: string;
  email: string;
  role: AppRole;
  workspaceBotId: string;  // Links to stored workspace token
}

function checkPermission(user: AppUser, action: 'read' | 'write' | 'delete' | 'manage'): boolean {
  const perms = ROLE_PERMISSIONS[user.role];
  switch (action) {
    case 'read': return perms.canRead;
    case 'write': return perms.canWrite;
    case 'delete': return perms.canDelete;
    case 'manage': return perms.canManageIntegration;
  }
}

// Express middleware
function requirePermission(action: 'read' | 'write' | 'delete' | 'manage') {
  return (req: any, res: any, next: any) => {
    if (!checkPermission(req.user, action)) {
      auditLog({
        userId: req.user.id,
        workspaceId: req.user.workspaceBotId,
        action: `${action}_denied`,
        resource: { type: 'endpoint', id: req.path },
        result: 'denied',
      });
      return res.status(403).json({ error: `Requires "${action}" permission` });
    }
    next();
  };
}

// Route examples
app.get('/api/pages/:id', requirePermission('read'), async (req, res) => {
  const notion = await tokenStore.getClient(req.user.workspaceBotId);
  const page = await safePageAccess(notion, req.params.id);
  res.json(page);
});

app.post('/api/pages', requirePermission('write'), async (req, res) => {
  const notion = await tokenStore.getClient(req.user.workspaceBotId);
  const page = await notion.pages.create(req.body);
  res.json(page);
});
```

**Audit logging — write to structured logs and optionally to a Notion database:**

```typescript
interface AuditEntry {
  timestamp: string;
  userId: string;
  workspaceId: string;
  action: string;
  resource: { type: string; id: string };
  result: 'success' | 'denied' | 'error';
  metadata?: Record<string, unknown>;
}

async function auditLog(entry: Omit<AuditEntry, 'timestamp'>): Promise<void> {
  const full: AuditEntry = {
    ...entry,
    timestamp: new Date().toISOString(),
  };

  // Always log to structured logging (searchable in log aggregator)
  console.log(JSON.stringify({ level: 'audit', ...full }));

  // Optionally write to a Notion audit database
  if (process.env.NOTION_AUDIT_DB_ID) {
    try {
      const notion = await tokenStore.getClient(entry.workspaceId);
      await notion.pages.create({
        parent: { database_id: process.env.NOTION_AUDIT_DB_ID },
        properties: {
          Action: { title: [{ text: { content: entry.action } }] },
          User: { rich_text: [{ text: { content: entry.userId } }] },
          Result: { select: { name: entry.result } },
          Resource: {
            rich_text: [{ text: { content: `${entry.resource.type}:${entry.resource.id}` } }],
          },
          Timestamp: { date: { start: full.timestamp } },
        },
      });
    } catch (error) {
      // Audit log writes should never crash the application
      console.error('Failed to write audit log to Notion:', error);
    }
  }
}

// Handle workspace deauthorization (user removes integration)
async function handleDeauthorization(botId: string): Promise<void> {
  const workspaces = await tokenStore.listWorkspaces();
  const workspace = workspaces.find(w => w.botId === botId);

  if (workspace) {
    await auditLog({
      userId: 'system',
      workspaceId: botId,
      action: 'workspace_deauthorized',
      resource: { type: 'workspace', id: botId },
      result: 'success',
      metadata: { workspaceName: workspace.name },
    });

    await tokenStore.revoke(botId);
  }
}
```

## Output

- Complete OAuth 2.0 flow for multi-workspace access (TypeScript + Python)
- Per-workspace token storage with encryption guidance
- Permission-aware API calls handling ObjectNotFound vs RestrictedResource
- Content discovery via `search` endpoint
- Application-level role system (admin/editor/viewer) with Express middleware
- Comprehensive audit logging to structured logs and optionally to Notion database
- Workspace deauthorization cleanup handler

## Error Handling

| Issue | Cause | Solution |
|-------|-------|----------|
| OAuth callback fails | Redirect URI mismatch | Must match exactly in integration settings (including trailing slash) |
| `invalid_grant` on token exchange | Code expired or already used | Authorization codes are single-use; restart OAuth flow |
| `ObjectNotFound` on page access | Page not shared with integration | User must share via "..." menu > Connections |
| `RestrictedResource` | Integration missing capability | Edit capabilities at notion.so/my-integrations |
| `Unauthorized` (401) | Token revoked by user | Prompt re-authorization; clean up stored token |
| State mismatch on callback | CSRF attack or session expired | Reject the callback; redirect to start OAuth again |

## Examples

### Full OAuth Integration (Express)

```typescript
import express from 'express';
import session from 'express-session';
import crypto from 'crypto';

const app = express();
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }));

app.get('/auth/notion', (req, res) => {
  const state = crypto.randomUUID();
  (req.session as any).oauthState = state;
  res.redirect(getAuthorizationUrl(state));
});

app.get('/auth/notion/callback', async (req, res) => {
  if (req.query.state !== (req.session as any).oauthState) {
    return res.status(403).send('Invalid state');
  }
  const tokenData = await exchangeCodeForToken(req.query.code as string);
  await tokenStore.store(tokenData);

  await auditLog({
    userId: tokenData.owner?.user?.id ?? 'unknown',
    workspaceId: tokenData.bot_id,
    action: 'workspace_authorized',
    resource: { type: 'workspace', id: tokenData.workspace_id },
    result: 'success',
  });

  res.redirect(`/dashboard?workspace=${encodeURIComponent(tokenData.workspace_name)}`);
});

app.get('/workspaces', async (_req, res) => {
  const workspaces = await tokenStore.listWorkspaces();
  res.json(workspaces);
});
```

## Resources

- [Notion OAuth Authorization](https://developers.notion.com/docs/authorization) — full OAuth guide
- [Create a Token (OAuth)](https://developers.notion.com/reference/create-a-token) — token exchange endpoint
- [Authentication Reference](https://developers.notion.com/reference/authentication) — auth header format
- [Notion Capabilities](https://developers.notion.com/docs/create-a-notion-integration#capabilities) — read/update/insert/delete
- [Sharing and Permissions](https://developers.notion.com/docs/create-a-notion-integration#sharing-and-permissions) — page-level model

## Next Steps

For migrating data to and from Notion, see `notion-migration-deep-dive`.

Related Skills

windsurf-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure Windsurf enterprise SSO, RBAC, and organization-level controls. Use when implementing SSO/SAML, configuring role-based seat management, or setting up organization-wide Windsurf policies. Trigger with phrases like "windsurf SSO", "windsurf RBAC", "windsurf enterprise", "windsurf admin", "windsurf SAML", "windsurf team management".

webflow-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure Webflow enterprise access control — OAuth 2.0 app authorization, scope-based RBAC, per-site token isolation, workspace member management, and audit logging for compliance. Trigger with phrases like "webflow RBAC", "webflow enterprise", "webflow roles", "webflow permissions", "webflow OAuth scopes", "webflow access control", "webflow workspace members".

vercel-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure Vercel enterprise RBAC, access groups, SSO integration, and audit logging. Use when implementing team access control, configuring SAML SSO, or setting up role-based permissions for Vercel projects. Trigger with phrases like "vercel SSO", "vercel RBAC", "vercel enterprise", "vercel roles", "vercel permissions", "vercel access groups".

veeva-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Veeva Vault enterprise rbac for enterprise operations. Use when implementing advanced Veeva Vault patterns. Trigger: "veeva enterprise rbac".

vastai-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Implement team access control and spending governance for Vast.ai GPU cloud. Use when managing multi-team GPU access, implementing spending controls, or setting up API key separation for different teams. Trigger with phrases like "vastai team access", "vastai RBAC", "vastai enterprise", "vastai spending controls", "vastai permissions".

twinmind-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure TwinMind Enterprise with on-premise deployment, custom AI models, SSO integration, and team-wide transcript sharing. Use when implementing enterprise rbac, or managing TwinMind meeting AI operations. Trigger with phrases like "twinmind enterprise rbac", "twinmind enterprise rbac".

supabase-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Implement custom role-based access control via JWT claims in Supabase: app_metadata.role, RLS policies with auth.jwt() ->> 'role', organization-scoped access, and API key scoping. Use when implementing role-based permissions, configuring organization-level access, building admin/member/viewer hierarchies, or scoping API keys per role. Trigger: "supabase RBAC", "supabase roles", "supabase permissions", "supabase JWT claims", "supabase organization access", "supabase custom roles", "supabase app_metadata".

speak-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure Speak for schools and organizations: SSO, teacher/student roles, class management, and usage reporting. Use when implementing enterprise rbac, or managing Speak language learning platform operations. Trigger with phrases like "speak enterprise rbac", "speak enterprise rbac".

snowflake-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure Snowflake enterprise RBAC with system roles, custom role hierarchies, SSO/SCIM integration, and least-privilege access patterns. Use when implementing role-based access control, configuring SSO with SAML/OIDC, or setting up organization-level governance in Snowflake. Trigger with phrases like "snowflake RBAC", "snowflake roles", "snowflake SSO", "snowflake SCIM", "snowflake permissions", "snowflake access control".

windsurf-enterprise-sso

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure enterprise SSO integration for Windsurf. Activate when users mention "sso configuration", "single sign-on", "enterprise authentication", "saml setup", or "identity provider". Handles enterprise identity integration. Use when working with windsurf enterprise sso functionality. Trigger with phrases like "windsurf enterprise sso", "windsurf sso", "windsurf".

shopify-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Implement Shopify Plus access control patterns with staff permissions, multi-location management, and Shopify Organization features. Trigger with phrases like "shopify permissions", "shopify staff", "shopify Plus organization", "shopify roles", "shopify multi-location".

sentry-enterprise-rbac

1868
from jeremylongshore/claude-code-plugins-plus-skills

Configure enterprise role-based access control, SSO/SAML2, and SCIM provisioning in Sentry. Use when setting up organization hierarchy, team permissions, identity provider integration, API token governance, or audit logging for compliance. Trigger: "sentry rbac", "sentry permissions", "sentry team access", "sentry sso setup", "sentry scim", "sentry audit log".