Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
87bd949
initial pass at adding clerk login flow to tina
ajolipa Mar 18, 2026
617ac64
improving error message
ajolipa Mar 19, 2026
0f81aca
adding some console logs to netlify function
ajolipa Mar 19, 2026
84a9b71
more logging
ajolipa Mar 19, 2026
1209620
updating org membership check on backend
ajolipa Mar 19, 2026
58e02fa
add 100 member org limit in backend auth
ajolipa Mar 19, 2026
97775a1
hide published field from non-admins; fix some permissions
ajolipa Mar 19, 2026
9c0ccf7
update publish field styling
ajolipa Mar 20, 2026
3ab3faf
improve published toggle; add checks for doc deletion/renaming
ajolipa Mar 20, 2026
48d2b02
update paths schema to match posts
ajolipa Mar 20, 2026
1a71a82
updating paths config stuff
ajolipa Mar 20, 2026
9977f8f
use signOut() instead of session.remove() for Clerk logout
jamiefolsom Mar 23, 2026
4b41517
return 404 for unpublished posts and paths in SSR mode
jamiefolsom Mar 23, 2026
bbe2150
add role-based UI restrictions for TinaCMS editors
jamiefolsom Mar 24, 2026
2fb2397
Merge pull request #583 from performant-software/fix/clerk-logout
ajolipa Mar 24, 2026
7dfa8d5
improve read-only UX for non-owned content
jamiefolsom Mar 24, 2026
5e98e1c
gate role-based UI behind SSO flag for non-Clerk site compatibility
jamiefolsom Mar 24, 2026
2bbc29a
admin redirect
camdendotlol Mar 24, 2026
9f6859f
reset config.json
camdendotlol Mar 24, 2026
fca5e15
one more newline
camdendotlol Mar 25, 2026
16bbe92
Merge pull request #593 from performant-software/cm/admin-redirect
ajolipa Mar 25, 2026
9d14906
moving 404 logic to child components
ajolipa Mar 25, 2026
6e6f4cb
Merge pull request #584 from performant-software/fix/unpublished-post…
ajolipa Mar 25, 2026
0c8f87b
fixing getUserRole function; disabling editing of published content
ajolipa Mar 25, 2026
d4637df
Merge pull request #586 from performant-software/fix/role-based-ui
ajolipa Mar 25, 2026
c130a42
fix custom netlify.toml script
ajolipa Mar 25, 2026
e0a7938
initial commit
camdendotlol Mar 25, 2026
06374e0
add handling for null document to role-ui
ajolipa Mar 25, 2026
8b84b7d
more troubleshooting
ajolipa Mar 25, 2026
3208903
WIP
camdendotlol Mar 26, 2026
e7b9e16
check in edge function too
camdendotlol Mar 26, 2026
51309ec
fixes
camdendotlol Mar 26, 2026
78846fc
Merge pull request #595 from performant-software/cm/rebuild-function
ajolipa Mar 26, 2026
6bf03fe
loading indicator (#596)
camdendotlol Mar 26, 2026
3cdb27d
Admin redirect (#597)
camdendotlol Mar 27, 2026
bef3444
Redirect edge function fixes (#598)
camdendotlol Mar 27, 2026
04598a0
add _astro to routes ignored by redirect (#599)
camdendotlol Mar 27, 2026
8540b3a
fix pathname (#600)
camdendotlol Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, envField } from 'astro/config';
import auth from 'auth-astro';
import { loadEnv } from 'vite';
import config from './public/config.json';

Expand All @@ -22,7 +21,7 @@ export default defineConfig({
},
output: STATIC_BUILD === 'true' ? 'static' : 'server',
adapter: netlify(),
integrations: [mdx(), sitemap(), react(), auth()],
integrations: [mdx(), sitemap(), react()],
vite: {
optimizeDeps: {
esbuildOptions: {
Expand Down
37 changes: 37 additions & 0 deletions docs/clerk-rbac-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Clerk RBAC Fixes

Tracking fixes and improvements for PR #577 (Clerk-based RBAC for TinaCMS).

## Quick Wins

- [ ] **Clerk logout doesn't fully sign out** — `session.remove()` should be `signOut()` in `tina/auth-provider.ts` (PR #583)
- [ ] **Unpublished posts/paths visible in SSR** — detail pages don't check published status; should return 404 (PR #584)
- [ ] **Dev script timeout** — `scripts/dev.sh` needs pre-build step to avoid Netlify CLI timeout (local only, not PRed)

## Known Limitations (PR #586)

- [ ] **`_ownershipNotice` appears as sort option** — The pseudo-field shows up in the collection list sort dropdown. Need to find a way to exclude it or use a different approach for injecting the banner.
- [ ] **CSS selectors depend on Tina's utility classes** — Form disabling targets classes like `.relative.w-full.flex-1.overflow-hidden` and `.border-b.border-gray-100`. These could break if TinaCMS updates its markup in a future version.
- [ ] **Pre-existing content without `creator` field is editable by all editors** — Posts/paths created before the RBAC feature have no `creator.id`, so `OwnershipNotice` treats them as owned by the current user. Need a migration strategy or a policy decision (e.g., admin-only for unclaimed content).

## Compatibility & Maintenance Questions

- [x] **Non-Clerk site compatibility** — Resolved: `cmsCallback` gates role-ui behind `useSSO`, and `getUserRole` defaults to admin when no Clerk user is found. Non-Clerk sites are unaffected by RBAC restrictions.
- [ ] **Migrate non-Clerk sites to Clerk** — Existing sites using native Tina user management (`TINA_PUBLIC_AUTH_USE_SSO=false`) should be migrated to Clerk for RBAC. Needs: a migration guide or script covering Clerk org setup, user invitations, env var changes (`CLERK_SECRET`, `TINA_PUBLIC_CLERK_PUBLIC_KEY`, `TINA_PUBLIC_CLERK_ORG_ID`, `TINA_PUBLIC_AUTH_USE_SSO=true`), and assigning `creator` fields to existing content so ownership works.
- [ ] **Retesting after Tina/Astro upgrades** — The CSS selectors and MutationObserver approach depend on Tina's internal DOM structure. Define a manual or automated test checklist to run after upgrading TinaCMS or Astro (sidebar locking, form disabling, ownership banner, save guard).
- [ ] **Adding new content types** — When new collections (beyond Posts/Paths) are added that editors should access, they need: the `_ownershipNotice` field, `creator` fields, `beforeSubmit` guard, and to NOT be in the `ADMIN_ONLY_COLLECTIONS` list. Document this as part of the "add a new collection" checklist.
- [ ] **Adding features to existing content types** — New fields on Posts/Paths are automatically covered by the CSS-disable approach (targets the entire form scroll area). But new field types with unusual DOM elements might not be caught. Test RBAC after adding fields.

## Pre-existing Issues (not caused by RBAC work)

- [ ] **Timeline MDX component not rendering in post preview** — Map and table components render correctly, but `<timeline>` renders as code. Component is registered in `PostContent.tsx` and used in `demo-of-post-functionality.mdx`. May be a data parsing issue with the HTML-encoded JSON in the `data` attribute.

## Visual Editing

- [ ] **Post title/author/date don't live-update in Tina preview** — These fields are server-rendered in `Post.astro` for fast page shell loading; only `body` is in the `PostContent.tsx` React component wired to `useTina()`. This split was deliberate (Derek's server islands work, `4229b63`; Rebecca kept it when adding visual editing, `27eaec4`). Fix would move metadata rendering into `PostContent.tsx`, trading server-render speed for live editability. Same issue likely applies to paths.

## Deferred (needs Rebecca's review)

- [ ] **#580 Improve error feedback** — Tina shows generic errors for unauthorized actions. Backend already returns descriptive messages but Tina's frontend doesn't surface them. Rebecca says this involves intercepting Tina's native error handling.
- [ ] **#581 Hide admin-only sidebar links** — Addressed in PR #586 with MutationObserver approach. Rebecca should review the implementation.
- [ ] **#580 (partial) Read-only fields for editors** — Addressed in PR #586 with CSS-disable approach. Rebecca should review.
161 changes: 124 additions & 37 deletions netlify/functions/tina.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import dotenv from 'dotenv';
import express from 'express';
import { createMediaHandler } from 'next-tinacms-s3/dist/handlers';
import ServerlessHttp from 'serverless-http';
import authConfig from '@root/auth.config';
import { Session } from '@auth/core/types';
import { Auth } from '@auth/core';
import { AuthJsBackendAuthProvider, TinaAuthJSOptions } from 'tinacms-authjs';
import { Clerk } from '@clerk/backend';
import type { IncomingMessage, ServerResponse } from 'http';

dotenv.config();

Expand All @@ -22,50 +21,138 @@ app.use(express.json());
app.use(cookieParser());

const isLocal = process.env.TINA_PUBLIC_IS_LOCAL === 'true';
const useSSO = process.env.TINA_PUBLIC_AUTH_USE_KEYCLOAK === 'true';
const useSSO = process.env.TINA_PUBLIC_AUTH_USE_SSO === 'true';

const ClerkBackendAuthentication = ({
secretKey,
allowList,
orgId,
}: {
secretKey: string;
// Ensure the user is the in allowList
allowList?: string[];
// Ensure the user is a member of the provided orgId
orgId?: string;
}) => {
const clerk = Clerk({
secretKey,
});

async function getSession(req: Request, options = authConfig): Promise<Session | null> {
// @ts-ignore
options.secret ??= process.env.AUTH_SECRET
options.trustHost ??= true
options.providers[0].options.clientId = process.env.AUTH_KEYCLOAK_ID
options.providers[0].options.clientSecret = process.env.AUTH_KEYCLOAK_SECRET
options.providers[0].options.issuer = process.env.AUTH_KEYCLOAK_ISSUER

const url = new URL(`/api/auth/session`, process.env.PUBLIC_BASE_URL)
const response = await Auth(new Request(url, { headers: req.headers }), options)
const { status = 200 } = response

const data = await response.json()

if (!data || !Object.keys(data).length) return null
if (status === 200) return data
throw new Error(data.message)
}

const CustomBackendAuth = () => {
return {
isAuthorized: async (req, res) : Promise<{ isAuthorized: true } | { isAuthorized: false, errorCode: number, errorMessage: string }> => {
// Validate the token here
const session = await getSession(req, authConfig);
if (!session || !session.user) {
return {
errorCode: 401,
errorMessage: "User is unauthenticated",
isAuthorized: false
isAuthorized: async (req: IncomingMessage, _res: ServerResponse) => {
const token = req.headers['authorization'];
const tokenWithoutBearer = token?.replace('Bearer ', '').trim();
const requestState = await clerk.authenticateRequest({
headerToken: tokenWithoutBearer,
});

if (requestState.status === 'signed-in') {
const user = await clerk.users.getUser(requestState.toAuth().userId);
if (orgId) {
// Get the list of member id's for the organization
const membershipList = (
await clerk.organizations.getOrganizationMembershipList({
organizationId: orgId,
limit: 100 //come back to this when we have orgs with more than 100 members
})
);
const orgUser = membershipList?.find((mem) => (mem.publicUserData?.userId === user.id));
// if the user is not in the list, they are not authorized
if (!orgUser) {
return {
isAuthorized: false as const,
errorMessage:
`User ${user.id} not authorized. Not a member of the provided organization (${orgId}).`,
errorCode: 401,
};
}
// otherwise, add the role to the user object
user.role = orgUser.role;
}
// if the user's email is not in the allowList, they are not authorized
const primaryEmail = user.emailAddresses.find(
({ id }) => id === user.primaryEmailAddressId
);

if ((primaryEmail && !allowList) || (primaryEmail && allowList?.includes(primaryEmail.emailAddress))) {
// now we've passed the first hurdle and it's time to check the specific permissions
// if the user is an admin, this is all we needed to know
if (user.role === 'org:admin') {
return { isAuthorized: true as const };
}
// non-admin users cannot delete
if (req.body?.query?.includes('DeleteDocument')) {
return {
isAuthorized: false as const,
errorMessage: 'You do not have permission to delete documents.',
errorCode: 401,
};
}
// non-admin users cannot delete
if (req.body?.query?.includes('RenameDocument')) {
return {
isAuthorized: false as const,
errorMessage: 'You do not have permission to rename documents.',
errorCode: 401,
};
}
// non-admin users can only edit paths and posts
if (req.body?.variables?.collection && !(req.body?.variables?.collection === 'post' || req.body?.variables?.collection === 'path')) {
return {
isAuthorized: false as const,
errorMessage: 'You do not have access to this collection.',
errorCode: 401,
};
}
for (const collection of ['path', 'post']) {
if (
req.body?.variables?.params
) {
// non-admin users can only edit paths and posts they created
if (req.body?.variables?.params[collection]?.creator?.id
&& req.body?.variables?.params[collection]?.creator?.id !== user.id
) {
return {
isAuthorized: false as const,
errorMessage: 'You may only edit content you created.',
errorCode: 401,
};
}

// non-admin users cannot publish posts or edit published posts
if (req.body?.variables?.params[collection]?.published) {
return {
isAuthorized: false as const,
errorMessage: 'You may not edit published content.',
errorCode: 401,
}
}
}
}
// non-admin users cannot publish posts or edit published posts
return { isAuthorized: true as const };
}
}
return {
isAuthorized: true,

if (requestState.reason === 'unexpected-error') {
console.error(requestState.message);
}
return {
isAuthorized: false as const,
errorMessage: 'User not authorized',
errorCode: 401,
};
},
}
}
};
};

const authProvider = isLocal
? LocalBackendAuthProvider()
: useSSO
? CustomBackendAuth()
? ClerkBackendAuthentication({
secretKey: process.env.CLERK_SECRET,
orgId: process.env.TINA_PUBLIC_CLERK_ORG_ID
})
: AuthJsBackendAuthProvider({
authOptions: TinaAuthJSOptions({
databaseClient,
Expand Down
Loading