コンテンツにスキップ

Multi-Tenancy

Greentic multi-tenancy is built around explicit runtime context, not hidden global configuration. A bundle can expose the same application packs to many tenants, restrict packs per tenant or team, and pass tenant identity through every component and host capability call.

The current model is defined by greentic-types and greentic-interfaces:

  • greentic-types::TenantCtx is the Rust type shared across host-side crates.
  • greentic:types-core@0.6.0 defines the canonical WIT tenant-ctx used by component interfaces.
  • greentic:interfaces-types@0.1.0 keeps compatibility fields for older host capability surfaces.
  • greentic-bundle materializes tenant/team access files and resolved manifests from bundle.yaml.
Bundle workspace
├── app packs # Digital worker applications and flows
├── extension providers # Messaging, state, secrets, telemetry, etc.
└── tenants/
└── <tenant>/
├── tenant.gmap # Tenant-level access policy
└── teams/
└── <team>/
└── team.gmap # Optional team-level policy overlay

Tenant and team directories are access scopes. They are not YAML tenant profiles and they do not store channel configuration directly. The runtime combines the selected tenant/team, access policy, app-pack mappings, setup state, and incoming message metadata to build the invocation context.

TenantCtx is the context carried with invocations, host calls, state, secrets, telemetry, and messaging. In greentic-types, it is richer than the old three-field examples:

pub struct TenantCtx {
pub env: EnvId,
pub tenant: TenantId,
pub tenant_id: TenantId,
pub team: Option<TeamId>,
pub team_id: Option<TeamId>,
pub user: Option<UserId>,
pub user_id: Option<UserId>,
pub session_id: Option<String>,
pub flow_id: Option<String>,
pub node_id: Option<String>,
pub provider_id: Option<String>,
pub trace_id: Option<String>,
pub i18n_id: Option<String>,
pub correlation_id: Option<String>,
pub attributes: BTreeMap<String, String>,
pub deadline: Option<InvocationDeadline>,
pub attempt: u32,
pub idempotency_key: Option<String>,
pub impersonation: Option<Impersonation>,
}

The duplicate pairs such as tenant / tenant_id, team / team_id, and user / user_id exist for compatibility with older interface surfaces. New code should keep them aligned by using the helpers such as TenantCtx::new(...), with_team(...), and with_user(...).

The canonical component WIT shape in greentic:types-core@0.6.0 uses the _id names:

record tenant-ctx {
tenant-id: tenant-id,
team-id: option<team-id>,
user-id: option<user-id>,
env-id: env-id,
trace-id: trace-id,
correlation-id: correlation-id,
deadline-ms: u64,
attempt: u32,
idempotency-key: option<string>,
i18n-id: string,
}

bundle.yaml is the workspace state file. It records app packs, extension providers, capabilities, remote catalogs, and optional app-pack mappings:

bundle.yaml
schema_version: 1
bundle_id: support-bundle
bundle_name: Support Bundle
locale: en
mode: create
app_packs:
- oci://ghcr.io/greenticai/packs/apps/support-agent:latest
app_pack_mappings:
- reference: oci://ghcr.io/greenticai/packs/apps/support-agent:latest
scope: tenant
tenant: acme
- reference: oci://ghcr.io/greenticai/packs/apps/field-service:latest
scope: team
tenant: acme
team: ops
extension_providers:
- oci://ghcr.io/greenticai/packs/messaging/messaging-webchat-gui:latest
- oci://ghcr.io/greenticai/packs/state/state-memory:latest
capabilities:
- greentic.cap.bundle_assets.read.v1

Mapping scopes are:

ScopeMeaning
globalThe app pack is available to every tenant/team that policy allows.
tenantThe app pack is mapped to one tenant.
teamThe app pack is mapped to one tenant team.

The wizard stores these mappings in JSON AnswerDocuments using app_pack_entries[].mapping.scope. The generated workspace persists them in bundle.yaml.

.gmap files are policy files, not tenant metadata documents. Each non-comment line is:

<pack>[/<flow>[/<node>]] = public|forbidden

Examples:

tenants/acme/tenant.gmap
_ = forbidden
support-agent = public
support-agent/intake = public
support-agent/admin = forbidden
tenants/acme/teams/ops/team.gmap
support-agent/admin = public
support-agent/admin/delete-ticket = forbidden

Evaluation uses the most specific matching rule. Team policy overlays tenant policy: if a team rule matches, it wins; otherwise the tenant rule is used. The default generated policy is _ = forbidden, so a pack, flow, or node must be made public before it is available to that tenant/team.

Wizard AnswerDocuments express the same policy as access_rules:

create-answers.json
{
"answers": {
"delegate_answer_document": {
"answers": {
"access_rules": [
{
"tenant": "acme",
"policy": "public",
"rule_path": "support-agent"
},
{
"tenant": "acme",
"team": "ops",
"policy": "public",
"rule_path": "support-agent/admin"
}
]
}
}
}
}

When the bundle workspace is synchronized, Greentic writes resolved manifests for each tenant/team:

resolved/
├── acme.yaml
└── acme.ops.yaml
state/resolved/
├── acme.yaml
└── acme.ops.yaml

Those files summarize the effective bundle, policy sources, extension providers, capabilities, and app-pack policy decisions:

resolved/acme.ops.yaml
version: 1
tenant: acme
team: ops
bundle:
bundle_id: support-bundle
bundle_name: Support Bundle
policy:
default: forbidden
source:
tenant_gmap: tenants/acme/tenant.gmap
team_gmap: tenants/acme/teams/ops/team.gmap
app_packs:
- reference: oci://ghcr.io/greenticai/packs/apps/support-agent:latest
policy: public
extension_providers:
- oci://ghcr.io/greenticai/packs/messaging/messaging-webchat-gui:latest
capabilities:
- greentic.cap.bundle_assets.read.v1

State and session host interfaces receive the tenant context. In WIT compatibility surfaces, state operations accept an optional tenant-ctx:

read: func(key: state-key, ctx: option<tenant-ctx>) -> result<list<u8>, host-error>;
write: func(key: state-key, bytes: list<u8>, ctx: option<tenant-ctx>) -> result<op-ack, host-error>;
delete: func(key: state-key, ctx: option<tenant-ctx>) -> result<op-ack, host-error>;

The important rule is that state keys are resolved together with TenantCtx. Components should pass the context they receive from the host and avoid embedding tenant IDs into ad hoc string keys.

Secrets use the canonical SecretScope from greentic-types:

pub struct SecretScope {
pub env: String,
pub tenant: String,
pub team: Option<String>,
}

Setup and extension packs can declare SecretRequirements with an expected scope. Runtime storage commonly uses a URI-like namespace such as:

secrets://{env}/{tenant}/{team-or-_}/{provider}/{key}

The scope is environment + tenant + optional team. Use the structured scope where possible; treat URI strings as storage addresses, not as the source of truth for authorization.

Ingress providers normalize platform-specific payloads into Greentic messages/events and attach tenant context before the flow runs. A WebChat, Teams, Slack, webhook, or timer event should all end up with the same identity fields:

  • env / env_id
  • tenant / tenant_id
  • team / team_id
  • user / user_id when known
  • session_id for conversation continuity
  • provider_id, flow_id, node_id
  • trace, correlation, attempt, deadline, and idempotency metadata

Messaging channels are therefore not the tenant hierarchy. They are ingress surfaces that resolve to a tenant/team context.

Components should trust the host-provided TenantCtx as the authority for the current invocation and should not accept tenant override fields from user payloads.

fn handle(input: Input, ctx: TenantCtx) -> Result<Output, Error> {
let identity = ctx.identity();
audit("tenant", identity.tenant_id.as_str());
// Use ctx for state, secrets, telemetry, and outbound calls.
// Do not derive tenant scope from untrusted input.
process_for_current_scope(input, ctx)
}

This keeps a component portable across single-tenant, shared multi-tenant, local, and cloud deployments.

Run one bundle workspace per customer. This is simple operationally and useful for strict data residency or customer-managed deployments.

Run one Greentic deployment with multiple tenant/team scopes. App packs and extensions can be shared, while access policy, secrets, state, sessions, and telemetry remain scoped by TenantCtx.

Run shared infrastructure for lower-risk tenants and dedicated workspaces for regulated tenants. The same pack can move between models because tenant identity is passed through the same TenantCtx and WIT contracts.

  1. Use gtc wizard --schema - Generate tenant/team access and app-pack mappings from the current JSON AnswerDocument schema.
  2. Keep .gmap for policy only - Do not put tenant profiles, channel settings, or secrets in tenant.gmap or team.gmap.
  3. Default deny - Keep _ = forbidden and explicitly publish the packs, flows, or nodes a tenant/team may run.
  4. Pass TenantCtx unchanged - State, secrets, telemetry, HTTP, and messaging calls should use the host-provided context.
  5. Keep compatibility fields aligned - If you construct TenantCtx manually, keep tenant with tenant_id, team with team_id, and user with user_id.
  6. Use structured scopes - Prefer SecretScope, TenantIdentity, and TenantContext over ad hoc path strings.