ArchitectureAuthentication & Authorization
Architecture

Authentication & Authorization

Technical reference for Jules (Harold) auth architecture — Magic.link, Permit.io ABAC, LaunchDarkly feature flags, multi-tenant schema isolation, and external portal access control.

Authentication & Authorization

Table of Contents


Overview

Jules uses three separate systems that work together for security:

LayerSystemResponsibility
AuthenticationMagic.linkVerifies identity via passwordless email
AuthorizationPermit.ioDecides what a verified user can do (ABAC)
Feature gatingLaunchDarklyControls which orgs can access which features

The GraphQL layer enforces both authentication and authorization through schema directives before any resolver business logic executes.


Passwordless flow

Jules does not store passwords. Authentication is entirely handled by Magic.link using DID (Decentralized Identity) tokens sent via email.

  1. The client calls the login endpoint with an email address.
  2. Magic.link sends a one-time magic link to that address.
  3. Clicking the link issues a short-lived DID token to the browser/client.
  4. Every subsequent API request attaches this token in the Authorization header.
  5. The API validates the token with magic-admin SDK on each request before processing.

No session state is stored server-side for internal users. The token itself carries all identity claims.

JWT structure and claims

After validation, the decoded DID token exposes the following claims relevant to Jules:

interface MagicUserMetadata {
  issuer: string;        // "did:ethr:0x..." — stable unique user ID
  email: string;         // user's email address
  publicAddress: string; // Ethereum address tied to the Magic keypair
}

The API augments these claims by looking up the user record in the org schema to attach:

interface JulesContext {
  userId: string;
  email: string;
  orgId: string;       // determines which PostgreSQL schema is queried
  orgSlug: string;     // e.g. "acme" → schema "acme_lotr_extr"
  role: string;        // internal role (admin, trader, viewer, …)
  isPortalUser: false;
}

This enriched context object is attached to every GraphQL request context and is available inside all resolvers as ctx.user.

Authenticating API requests

All requests to the GraphQL endpoint must include a valid token:

POST /graphql
Authorization: Bearer <magic-did-token>
Content-Type: application/json

{ "query": "{ me { id email } }" }

Requests without a valid token are rejected at the middleware level before reaching any resolver. The HTTP response is 401 Unauthorized.

For local development, the token can be obtained by running the login flow against the Magic.link testnet. See the MAGIC_SECRET_KEY env variable in .env.example.


Authorization: Permit.io (ABAC)

Why ABAC, not RBAC

Role-Based Access Control (RBAC) is insufficient for Jules because permissions depend on attributes beyond just the user's role. For example:

  • A trader can edit an offer only if they own it or are in the same org.
  • A CSM can view account health data only for their assigned accounts.
  • An external portal user can view shipments only for the company they are linked to.

Attribute-Based Access Control (ABAC) evaluates conditions (attributes of the user, the resource, and the environment) at check time, making this kind of context-sensitive policy possible.

Policy structure

Permit.io policies are defined as (subject, action, resource, condition) tuples. The three key concepts:

Resource: a domain object (e.g., offer, shipment, invoice).

Action: what is being attempted (e.g., read, create, update, delete, approve).

Condition: an attribute expression that must evaluate to true for the check to pass (e.g., resource.orgId == user.orgId).

Example policy in pseudo-YAML:

resource: offer
action: update
subject:
  role: trader
condition:
  - resource.orgId == user.orgId
  - resource.assignedUserId == user.id OR user.role == "admin"

Policies are managed in the Permit.io dashboard. Do not hardcode permission logic in resolver code — keep it in Permit.io so it can be audited and updated without a deployment.

Permission checks in resolvers

The permit client is available on the GraphQL context as ctx.permit. Direct checks look like:

const allowed = await ctx.permit.check(
  ctx.user.id,      // subject
  'update',         // action
  {
    type: 'offer',
    id: offerId,
    attributes: {
      orgId: offer.orgId,
      assignedUserId: offer.assignedUserId,
    },
  }
);

if (!allowed) {
  throw new ForbiddenError('Insufficient permissions to update this offer');
}

In practice, most resolvers do not call ctx.permit.check directly. Instead they rely on the @portalPermit schema directive (see below), which handles this call before the resolver body executes.

GraphQL directives

Two directives enforce security at the schema level, declared in the root schema:

directive @isAuthenticated on FIELD_DEFINITION
directive @portalPermit(resource: String!, action: String!) on FIELD_DEFINITION

@isAuthenticated

Verifies that a valid Magic.link token is present and that ctx.user is populated. Used on every query and mutation by default. Raises AuthenticationError if no valid token is found.

type Query {
  offers: [Offer!]! @isAuthenticated
  me: User @isAuthenticated
}

@portalPermit(resource, action)

Extends @isAuthenticated with a Permit.io policy check. Use this on any field where access must be gated beyond just "logged in". Raises ForbiddenError if the Permit.io check returns false.

type Mutation {
  updateOffer(id: ID!, input: OfferInput!): Offer!
    @portalPermit(resource: "offer", action: "update")

  deleteOffer(id: ID!): Boolean!
    @portalPermit(resource: "offer", action: "delete")

  approveInvoice(id: ID!): Invoice!
    @portalPermit(resource: "invoice", action: "approve")
}

The directive implementation extracts the id argument from the resolver call, fetches the relevant resource attributes, and performs the Permit.io check before the resolver function body executes. If the check fails, the resolver never runs.


Multi-tenant Schema Isolation

Schema naming convention

Jules uses one PostgreSQL schema per organisation. There is no shared application schema that mixes tenant data. The naming pattern is:

{orgSlug}_lotr_extr

Examples:

  • acme_lotr_extr
  • metallica_lotr_extr
  • greentech_lotr_extr

The lotr_extr suffix is a legacy artifact from the original project codename. The ~70 schemas on the demo environment follow this exact pattern.

How Prisma scopes queries at runtime

Prisma's schema prefix is set dynamically at connection time using search_path. The orgSlug from ctx.user determines which schema is active for the lifetime of that request:

// Simplified — actual implementation may use a Prisma client factory
const prisma = getPrismaClient({
  schema: `${ctx.user.orgSlug}_lotr_extr`,
});

Every Prisma query in a request context automatically targets the correct schema. There is no per-query SET search_path needed because the connection is scoped to that org for the duration of the request.

This means a resolver cannot accidentally read another org's data. If a bug caused ctx.user.orgSlug to be set to a wrong value, the query would hit the wrong schema — but it would still hit only one schema, not all of them.

Cross-org leakage prevention

The following invariants prevent cross-org data leakage:

  1. ctx.user.orgSlug is derived from the validated Magic.link token + database lookup — it cannot be spoofed via a request parameter.
  2. Prisma is never initialized with a wildcard or superuser search_path.
  3. No resolver accepts orgId or schema as a user-supplied argument for data routing.
  4. The MCP database connection used for tooling is read-only and is never used in production request paths.

External Portal Authentication

Portal users are external parties — suppliers, customers, or logistics partners — who access a limited subset of Jules data through a dedicated portal interface. They are not internal team members.

Portal user identity

Portal users authenticate differently from internal users. Their JWT payload has a distinct structure:

interface PortalUserContext {
  userId: string;          // external user's ID in external_users_to_companies
  email: string;
  orgId: string;           // the Jules org they have been granted access to
  companyId: string;       // the specific company (counterparty) they represent
  isPortalUser: true;      // distinguishes portal from internal users
  permissions: string[];   // explicit permission set, e.g. ["shipment:read", "document:read"]
}

The isPortalUser: true flag is critical. Resolvers and directives check this to apply the portal-specific permission set rather than the internal Permit.io policy tree.

Invite flow

External portal users are provisioned through an invite system:

  1. An internal user generates a share token or sends an email invite from the Jules UI.
  2. The invite records a row in external_users_to_companies linking the external email to a specific companyId within the org.
  3. The external user clicks the invite link, authenticates via Magic.link (same passwordless flow), and is issued a portal-scoped JWT.
  4. On first login, the portal JWT is populated with the permissions defined in their external_users_to_companies record.

The external_users_to_companies table is the source of truth for portal access. Revoking access means deleting or deactivating the row in that table — the user's Magic.link account may still exist, but they will no longer receive portal-scoped permissions on login.

Redis permission cache

To avoid a database lookup on every portal request, resolved portal permissions are cached in Redis:

Key:   portal:permit:{orgId}:{externalUserId}
Value: JSON array of permission strings
TTL:   typically 5–15 minutes (check env config)

When a portal user makes a request:

  1. The API checks Redis for portal:permit:{orgId}:{externalUserId}.
  2. On cache hit: permissions are loaded from cache directly.
  3. On cache miss: permissions are fetched from external_users_to_companies, written to Redis with TTL, then used.

Important: when an admin revokes portal access, the external_users_to_companies row is removed, but the Redis cache may still be valid until TTL expires. For immediate revocation, the relevant cache key must be explicitly invalidated. There should be a utility function to handle this — check src/api/ExternalUser or the permission invalidation service.


Feature Flags: LaunchDarkly

LaunchDarkly is used for progressive rollouts and per-organization feature enablement. It sits on top of authentication and authorization — a user may be authenticated and authorized, but still be blocked from a feature if the flag is off for their org.

Per-organization enablement

Feature flags are evaluated using the org as the targeting context. This allows rolling out a feature to specific orgs before a general release:

const ldClient = ctx.launchDarkly;

const flagEnabled = await ldClient.variation(
  'margin-pl-feature',           // flag key
  {
    kind: 'organization',
    key: ctx.user.orgId,
    name: ctx.user.orgSlug,
  },
  false                           // default value if SDK fails
);

Flag keys follow kebab-case and correspond to feature names. Existing flags are managed in the LaunchDarkly dashboard — ask the lead dev or PM before creating a new flag to avoid proliferation.

Checking flags in resolvers

There are two common patterns:

Hard gate — throw if flag is off:

const enabled = await ctx.launchDarkly.variation(
  'new-invoice-workflow',
  { kind: 'organization', key: ctx.user.orgId },
  false
);

if (!enabled) {
  throw new ForbiddenError('Feature not available for your organization');
}

Soft gate — return null/empty to hide UI elements:

const enabled = await ctx.launchDarkly.variation(
  'budget-variance-panel',
  { kind: 'organization', key: ctx.user.orgId },
  false
);

if (!enabled) return null;

// ... rest of resolver

Never check LaunchDarkly flags on the frontend as the sole gate. The API must always enforce the gate server-side as well.


Adding Auth to a New Resolver

Follow these steps whenever you add a new query or mutation:

Step 1 — Determine the security requirement:

RequirementDirective to use
Internal user, no resource-level check@isAuthenticated
Internal user, resource-level check@portalPermit(resource: "...", action: "...")
Portal user access@portalPermit + verify ctx.user.isPortalUser in resolver
Feature-gated@isAuthenticated + LaunchDarkly check in resolver body

Step 2 — Add the directive to the schema definition:

# harold-api/src/api/YourModule/schema.graphql

type Mutation {
  createYourResource(input: YourInput!): YourResource!
    @portalPermit(resource: "your_resource", action: "create")
}

Step 3 — Ensure the resource type exists in Permit.io:

If your_resource is a new resource type, it must be added in the Permit.io dashboard with the appropriate actions and conditions before the directive can evaluate it. Deploying before this is done will cause all checks to fail (deny by default).

Step 4 — Access user context in the resolver:

// harold-api/src/api/YourModule/resolver.ts

export const createYourResource = async (
  _: unknown,
  args: { input: YourInput },
  ctx: Context
) => {
  // ctx.user is guaranteed non-null here because @portalPermit
  // has already verified authentication and authorization.
  const { orgSlug, userId } = ctx.user;

  // Prisma is already scoped to the correct org schema.
  return ctx.prisma.yourResource.create({
    data: {
      ...args.input,
      createdBy: userId,
    },
  });
};

Step 5 — For portal-accessible resolvers, add a portal permissions check:

if (ctx.user.isPortalUser) {
  const canAccess = ctx.user.permissions.includes('your_resource:create');
  if (!canAccess) {
    throw new ForbiddenError('Portal users cannot create this resource');
  }
}

Security Checklist

Use this when reviewing a PR that touches resolvers, schema definitions, or auth-related code:

  • Every new query and mutation has either @isAuthenticated or @portalPermit — no unprotected fields.
  • @portalPermit resource and action strings match an existing Permit.io resource definition.
  • No resolver accepts orgId, schema, or tenantId as a user-supplied routing argument.
  • No hardcoded permission logic in resolver bodies that duplicates or bypasses Permit.io.
  • LaunchDarkly flag checks are enforced server-side, not only on the frontend.
  • Portal access revocation path also invalidates the Redis cache key portal:permit:{orgId}:{userId}.
  • New Prisma queries do not use a raw $queryRaw with a user-supplied schema name.
  • ctx.user.isPortalUser is checked explicitly in any resolver that behaves differently for portal vs internal users.