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:
parent
7ed9b9d7a2
commit
815c4423ce
|
|
@ -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' } );
|
||||
};
|
||||
}
|
||||
|
|
@ -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 ) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 ) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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=<current-url></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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue