notion-enterprise-rbac
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/clientv2+ 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:
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:
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:
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:
// 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:
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:
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
searchendpoint - 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)
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 — full OAuth guide
- Create a Token (OAuth) — token exchange endpoint
- Authentication Reference — auth header format
- Notion Capabilities — read/update/insert/delete
- Sharing and Permissions — page-level model
Next Steps
For migrating data to and from Notion, see notion-migration-deep-dive.