Add requireAccess middleware, products admin route, and lookup-email endpoint

- requireAccess.ts: flexible role+product access rules for per-service gating
- PATCH /api/admin/users/:id/products: superadmin can assign products to users
- POST /api/auth/lookup-email: server-to-server email→userId lookup via SERVICE_SECRET
- workspace/index.html: documented per-service access control design

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-15 07:06:39 +02:00
parent 7ed9b9d7a2
commit 815c4423ce
4 changed files with 194 additions and 3 deletions

View File

@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from 'express';
export type AccessRule = { role: string; product?: string };
export function requireAccess( rules: AccessRule[] )
{
return ( req: Request, res: Response, next: NextFunction ): void =>
{
const auth = req.auth;
if ( !auth ) { res.status( 401 ).json( { error: 'Not authenticated' } ); return; }
if ( auth.roles.includes( 'superadmin' ) ) { next(); return; }
const allowed = rules.some( rule =>
auth.roles.includes( rule.role ) &&
( !rule.product || auth.products.includes( rule.product ) )
);
if ( allowed ) { next(); return; }
res.status( 403 ).json( { error: 'Forbidden' } );
};
}

View File

@ -21,6 +21,30 @@ router.get( '/users', requireAdmin, ( _req, res ) =>
res.json( all );
} );
// PATCH /api/admin/users/:id/products — set products on a user (superadmin only)
router.patch( '/users/:id/products', requireSuperAdmin, ( req, res ) =>
{
const { id } = req.params;
const target = users.findById( id );
if ( !target )
{
res.status( 404 ).json( { error: 'User not found' } );
return;
}
const { products } = req.body as { products?: string[] };
if ( !Array.isArray( products ) || !products.every( p => typeof p === 'string' ) )
{
res.status( 400 ).json( { error: 'products must be an array of strings' } );
return;
}
const now = new Date().toISOString();
const userProducts = products.map( productId => ( { id: productId, acquiredAt: now, source: 'manual' } ) );
users.update( id, { products: userProducts } );
res.json( { id, products: userProducts } );
} );
// PATCH /api/admin/users/:id/roles — set roles on a user (superadmin only)
router.patch( '/users/:id/roles', requireSuperAdmin, ( req, res ) =>
{

View File

@ -251,6 +251,27 @@ router.get( '/reset-token-email', ( req, res ) =>
res.json( { email: user.email } );
} );
// POST /api/auth/lookup-email — server-to-server; requires SERVICE_SECRET
router.post( '/lookup-email', ( req, res ) =>
{
const secret = process.env.SERVICE_SECRET;
const auth = req.headers.authorization;
if ( !secret || auth !== `Bearer ${secret}` )
{
res.status( 401 ).json( { error: 'Unauthorized' } );
return;
}
const { email } = req.body as { email?: string };
if ( !email ) { res.status( 400 ).json( { error: 'Email required' } ); return; }
const user = users.findByEmail( email );
if ( !user ) { res.status( 404 ).json( { error: 'No account found for that email' } ); return; }
res.json( { id: user.id, email: user.email } );
} );
// GET /api/auth/me
router.get( '/me', requireAuth, ( req, res ) =>
{

View File

@ -122,12 +122,29 @@
<div class="card">
<h3>Products</h3>
<p>
Each user record has a <code>products</code> array. When a purchase is confirmed
(planned: via a Polar webhook), a product entry is appended. Apps read the product
list from the JWT at runtime — no Polar dependency needed outside the webhook handler.
Each user record has a <code>products</code> array. Products can be assigned
manually via <code>PATCH /api/admin/users/:id/products</code> (superadmin only)
or automatically when a purchase is confirmed (planned: Polar webhook).
Apps read the product list from the JWT at runtime — no round-trip to
rokojori-auth needed.
</p>
</div>
<div class="card">
<h3>Server-to-server lookup</h3>
<p>
Other rokojori services can resolve an email address to a user ID without
a user JWT — useful for migrating member records from email-based to
ID-based storage. The caller authenticates with a shared
<code>SERVICE_SECRET</code> environment variable, not a user token.
Returns <code>{ id, email }</code> or 404.
</p>
<div class="tags">
<span class="tag">POST /api/auth/lookup-email</span>
<span class="tag">Authorization: Bearer SERVICE_SECRET</span>
</div>
</div>
<div class="card">
<h3>Rate limiting</h3>
<p>
@ -191,6 +208,7 @@
middleware/
requireAuth.ts — JWT verification (cookie or Authorization header)
requireAdmin.ts — role guards (requireAdmin, requireSuperAdmin)
requireAccess.ts — flexible role+product access rules (copy into client services)
email/
EmailSender.ts — interface
SMTPEmailSender.ts
@ -260,6 +278,112 @@
</div>
</section>
<section>
<h2>Per-service access control</h2>
<div class="card">
<h3>Design</h3>
<p>
Each rokojori service defines an <strong>access rule list</strong> — a small array
that declares who is allowed in. The JWT already carries both <code>roles</code>
and <code>products</code>, so no round-trip to rokojori-auth is needed at request time.
</p>
<p style="margin-top:0.75rem">
<code>superadmin</code> is implicitly allowed on every service. No service needs
to include it in its rule list.
</p>
</div>
<div class="card">
<h3>AccessRule type</h3>
<pre><code>type AccessRule = {
role: string; // required — user must have this role
product?: string; // optional — user must also have this product
};</code></pre>
<p style="margin-top:0.75rem">
Multiple rules are <strong>OR</strong>-combined: access is granted if
<em>any</em> rule matches. Within a single rule, <code>role</code> and
<code>product</code> are <strong>AND</strong>-combined.
</p>
</div>
<div class="card">
<h3>Example — styles.rokojori.com</h3>
<pre><code>// Grant access to:
// • any admin (role alone is sufficient)
// • any user who has the "styles" product
// • any user who has the "premium" product
const rules: AccessRule[] = [
{ role: 'admin' },
{ role: 'user', product: 'styles' },
{ role: 'user', product: 'premium' },
];</code></pre>
</div>
<div class="card">
<h3>requireAccess middleware</h3>
<pre><code>// source/server/middleware/requireAccess.ts
import { Request, Response, NextFunction } from 'express';
type AccessRule = { role: string; product?: string };
export function requireAccess( rules: AccessRule[] )
{
return ( req: Request, res: Response, next: NextFunction ): void =>
{
const auth = req.auth;
if ( !auth ) { res.status( 401 ).json( { error: 'Not authenticated' } ); return; }
if ( auth.roles.includes( 'superadmin' ) ) { next(); return; }
const allowed = rules.some( rule =>
auth.roles.includes( rule.role ) &&
( !rule.product || auth.products.includes( rule.product ) )
);
if ( allowed ) { next(); return; }
res.status( 403 ).json( { error: 'Forbidden' } );
};
}</code></pre>
<p style="margin-top:0.75rem">
Use after <code>requireAuth</code>. For browser pages that should redirect
rather than return JSON, check <code>req.accepts('html')</code> in the 401/403
branches and redirect to
<code>account.rokojori.com/login?redirect=&lt;current-url&gt;</code>
or back to <code>/</code>.
</p>
<div class="tags">
<span class="tag">source/server/middleware/requireAccess.ts</span>
</div>
</div>
<div class="card">
<h3>Configuration per service</h3>
<p>
Each service defines its own rules inline where the router is mounted.
Copy <code>requireAccess.ts</code> into the service's middleware folder —
it has no dependencies beyond the shared <code>AuthPayload</code> type
from <code>requireAuth.ts</code>.
</p>
</div>
<div class="card">
<h3>Admin API for product management</h3>
<p>
Superadmins can set the full product list on any user via the admin route.
The body is an array of product ID strings; the server records
<code>acquiredAt</code> (now) and <code>source: "manual"</code> for each.
The updated list is reflected in the user's next JWT.
</p>
<div class="tags">
<span class="tag">PATCH /api/admin/users/:id/products</span>
<span class="tag">superadmin only</span>
</div>
</div>
</section>
<section>
<h2>Planned</h2>