DSH Plugin Store
Back to home

GuoMonth

dsh-multi-tenant

Multi-tenant SaaS extension for DeepSeek Harness (DSH): tenant identity, session isolation, authorization, tenant-aware MCP, and audit.

Stars
1
Language
TypeScript
Created
Aug 14, 2026
Updated
Aug 14, 2026
MCP
GitHub repo

Introduction

dsh-multi-tenant

Multi-tenant SaaS extension for DeepSeek Harness (DSH): tenant identity, session ownership, authorization boundaries, tenant-aware MCP, and audit.

Status: early development / architecture bootstrap. This repository implements the multi-tenant core contract only. It is not a complete SaaS security solution — see What this core is not.


What this core does

Given an authenticated TenantPrincipal, dsh-multi-tenant owns and authorizes access to opaque DSH session ids through a fail-closed, durable-store-compatible ownership contract.

Concretely, it provides two Cordis services — ctx.tenantSessionStore (the ownership-storage seam) and ctx.multiTenant (ownership + authorization) — that together:

  • abstracts the authenticated principal (TenantPrincipal),
  • owns sessions with claim-once, immutable ownership,
  • enforces the tenant boundary unconditionally (no role crosses it),
  • authorizes fail-closed (unknown and foreign sessions are both denied),
  • defines a storage seam (TenantSessionStore) so ownership persistence can move to a durable store without a breaking API change.

What this core is not

  • ❌ Authentication / HTTP transport (no JWT, cookies, web login)
  • ❌ Transport authorization / WebSocket filtering / events.mux / events.host
  • ❌ MCP client or tenant-aware MCP credential pooling
  • ❌ Downstream data isolation / ERP token
  • ❌ Audit persistence
  • ❌ UI / billing / dashboards
  • ❌ An RBAC / role-policy framework

These are all on the roadmap. The core is the middle of the future chain:

Authenticated Transport
        ↓
TenantPrincipal
        ↓
dsh-multi-tenant Core          ← this repository (Principal + Ownership + Authorization)
        ↓
Session ACL
        ↓
Tenant-aware MCP / business credentials
        ↓
Downstream tenant validation

Architecture

Browser / SaaS client
        |
        | authenticated identity
        v
Tenant-aware connection / API boundary
        |
        | TenantPrincipal
        v
Session authorization
        |
        +---------------------+
        |                     |
        v                     v
Shared DeepSeek Harness   Tenant-aware MCP
Agent Loop / LLM / Tools  credential pool
        |
        v
Session persistence
        |
        v
Audit / usage store

Design principles

  • Shared runtime, logical isolation — one Harness process, many tenants, separated by authorization rather than by process or fork.
  • Fail closed — unknown sessions and unauthenticated identities are denied.
  • Identity is server-derivedTenantPrincipal comes from the authenticated boundary, never from a client-supplied field.
  • Claim-once ownership — a session's owner is immutable; a conflicting claim is denied, never overwritten.
  • Streams are authorization surfaces — sessions, RPC, and tool/MCP streams are each a boundary, not just the HTTP entry point.
  • Defense in depth — this core is one layer; it does not replace the authenticated boundary or downstream tenant validation.
  • Prefer plugins over forks — build on DSH's public plugin/service seams.

Install

dsh plugin --profile web add github:GuoMonth/dsh-multi-tenant

The package declares dsh.bundle, so this appends its patch layer to the profile and mounts both ctx.tenantSessionStore and ctx.multiTenant.

Core API

Types

interface TenantPrincipal {
  tenantId: string
  userId: string
  roles: readonly string[]
}

interface SessionOwner {
  tenantId: string
  userId: string
}

TenantSessionStore (service seam, ctx.tenantSessionStore)

The storage seam is a Cordis Service, not a plain interface: it is provided by a backend plugin and consumed by MultiTenantService. claim is atomic (single operation, not get-then-set) so a durable backend can map it to INSERT … ON CONFLICT:

type ClaimResult = 'created' | 'idempotent' | 'conflict'

abstract class TenantSessionStore extends Service {
  claim(sessionId: string, owner: SessionOwner): Promise<ClaimResult>
  get(sessionId: string): Promise<SessionOwner | undefined>
}

There is deliberately no release/delete in the v0 contract: ownership is claim-once and immutable. InMemoryTenantSessionStore is the default provider — a process-local Map, intended for development/bootstrap only, not production persistence. A future durable backend swaps the tenantSessionStore provider without touching MultiTenantService.

MultiTenantService (ctx.multiTenant)

Consumes ctx.tenantSessionStore (declared via static inject). All methods are async so a durable store can be adopted without a breaking change.

MethodSemantics
claimSession(sessionId, principal)Claim-once. Unclaimed → success; same owner → idempotent; different owner → SessionOwnershipConflictError.
getSessionOwner(sessionId)Trusted-facing lookup; returns the owner or undefined.
canAccessSession(principal, sessionId)Fail-closed boolean. Same tenant + same owner → true; else false.
assertSessionAccess(principal, sessionId)Like above, but throws a uniform SessionAccessDeniedError.

Authorization semantics:

  • Unknown session → denied.
  • Tenant mismatch → denied (unconditional; checked before anything else).
  • Same tenant, different user → denied (ownership only; no RBAC yet).
  • Same tenant, same user → allowed.

Identifiers (sessionId, tenantId, userId) are opaque: the core never parses a tenant id out of a session id, never uses prefix-based authorization, and never assumes UUID/numeric shapes — only opaque exact-match identity.

Error privacy

assertSessionAccess throws a single, non-enumerating SessionAccessDeniedError ("Access to session denied."). Unknown sessions and foreign sessions are indistinguishable, and the error never carries the owner's tenant or user id. Internal diagnostic reasons (UNKNOWN_SESSION, TENANT_MISMATCH, USER_MISMATCH) exist for tests/audit/observability but are not part of the public authorization result.

Security boundary

Cordis / DSH scope is a composition and visibility mechanism — service isolation and dependency wiring. It is not by itself a multi-tenant security boundary.

A production deployment must enforce isolation across layers:

authenticated request boundary
        +
session ACL                          ← this core
        +
tenant-aware MCP / business token
        +
downstream ERP / business API tenant validation

Do not treat the in-memory store or the Cordis scope as the security perimeter.

Roadmap

  • project bootstrap
  • TenantPrincipal / SessionOwner
  • claim-once session ownership
  • fail-closed core authorization
  • TenantSessionStore seam (in-memory)
  • runtime invariant validation
  • Loader integration test
  • durable TenantSessionStore (PostgreSQL / MySQL / Redis / remote)
  • HTTP principal/auth integration
  • session RPC authorization
  • WebSocket mux filtering
  • approval/question RPC ownership
  • tenant-aware MCP
  • token usage / audit
  • DSH Web integration tests
  • npm prerelease

Development

pnpm install
pnpm typecheck
pnpm test
pnpm build
  • build runs tsdown, emitting dist/index.mjs + dist/index.d.mts.
  • typecheck runs tsc --noEmit.
  • test runs unit, security, and a real Cordis Loader integration test.

License

MIT