Skip to main content
Glama
data-model.md24.8 kB
# Data Model: Multi-Tenant Billable MCP Server (v2.0) **Date**: 2025-10-13 **Branch**: `003-we-need-to` **Phase**: 1 (Design) **Input**: Research findings from `research.md`, functional requirements FR-001 to FR-027 --- ## Overview v2.0 introduces multi-tenant architecture with 7 new Supabase tables (plus Supabase Auth managed tables) to support organization-based isolation, API key management, Stripe billing, and audit logging. All models use database-enforced Row Level Security (RLS) policies for tenant isolation. ### New Entities 1. **organizations** - Tenant entity representing property management companies 2. **organization_members** - Many-to-many user-organization relationship 3. **hostaway_credentials** - Encrypted Hostaway API credentials per organization 4. **api_keys** - MCP authentication tokens for AI agent access 5. **subscriptions** - Stripe billing subscription per organization 6. **usage_metrics** - Aggregated API usage per organization per month 7. **audit_logs** - MCP tool invocation logs for compliance **Note**: `auth.users` table is managed by Supabase Auth (not custom application table) --- ## Entity Definitions ### 1. organizations **Purpose**: Represents a tenant (property management company or individual host) **Source**: Created on user signup via Next.js dashboard **Requirements**: FR-001, FR-004, FR-023 ```sql CREATE TABLE organizations ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL CHECK (length(name) >= 1 AND length(name) <= 255), owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, stripe_customer_id TEXT UNIQUE, -- Stripe customer ID for billing created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes CREATE INDEX idx_organizations_owner ON organizations(owner_user_id); CREATE INDEX idx_organizations_stripe ON organizations(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; -- RLS Policy ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users access own organizations" ON organizations FOR ALL USING ( id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); ``` **Validation Rules**: - `name`: 1-255 characters, required - `owner_user_id`: Must reference existing auth.users record - `stripe_customer_id`: Unique, nullable (set after Stripe customer creation) **Relationships**: - **1:N** with organization_members (one org has many members) - **1:N** with api_keys (one org has many API keys) - **1:1** with subscriptions (one org has one active subscription) - **1:N** with hostaway_credentials (one org has one Hostaway account, but supports historical records) --- ### 2. organization_members **Purpose**: Many-to-many relationship between users and organizations (supports multi-user orgs in future) **Source**: Created on user signup (owner) or invitation flow (future) **Requirements**: FR-001, FR-023 ```sql CREATE TYPE organization_role AS ENUM ('owner', 'admin', 'member'); CREATE TABLE organization_members ( organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, role organization_role NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (organization_id, user_id) ); -- Indexes CREATE INDEX idx_org_members_user ON organization_members(user_id); CREATE INDEX idx_org_members_org ON organization_members(organization_id); -- RLS Policy ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users see own memberships" ON organization_members FOR SELECT USING (user_id = auth.uid()); -- Policy: Only owners can invite members (future enhancement) CREATE POLICY "Owners manage members" ON organization_members FOR ALL USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() AND role = 'owner' ) ); ``` **Validation Rules**: - `organization_id`: Must reference existing organization - `user_id`: Must reference existing auth.users record - `role`: Must be one of 'owner', 'admin', 'member' - Composite primary key prevents duplicate memberships **Relationships**: - **N:1** with organizations (many members belong to one org) - **N:1** with auth.users (many memberships for one user across different orgs) --- ### 3. hostaway_credentials **Purpose**: Stores encrypted Hostaway account credentials for each organization **Source**: Created when user connects Hostaway account via dashboard settings **Requirements**: FR-002, FR-003, FR-022 ```sql CREATE TABLE hostaway_credentials ( id BIGSERIAL PRIMARY KEY, organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, account_id TEXT NOT NULL CHECK (length(account_id) >= 1), encrypted_secret_key TEXT NOT NULL, -- Encrypted via pgsodium.crypto_secretbox credentials_valid BOOLEAN NOT NULL DEFAULT true, last_validated_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(organization_id) -- One active credential per org ); -- Indexes CREATE INDEX idx_hostaway_creds_org ON hostaway_credentials(organization_id); CREATE INDEX idx_hostaway_creds_valid ON hostaway_credentials(credentials_valid) WHERE credentials_valid = true; -- RLS Policy ALTER TABLE hostaway_credentials ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users access own org credentials" ON hostaway_credentials FOR SELECT USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); -- Policy: Only owners can insert/update credentials CREATE POLICY "Owners manage credentials" ON hostaway_credentials FOR INSERT, UPDATE USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() AND role IN ('owner', 'admin') ) ); ``` **Validation Rules**: - `account_id`: Non-empty string (Hostaway account ID format) - `encrypted_secret_key`: Required, encrypted via Supabase Vault (pgsodium) - `credentials_valid`: Boolean flag, set to false if Hostaway returns 401 - Unique constraint ensures one active credential per organization **Encryption Pattern**: ```sql -- Encrypt on insert (via application or database trigger) INSERT INTO hostaway_credentials (organization_id, account_id, encrypted_secret_key) VALUES ( 123, 'ACC_12345', pgsodium.crypto_secretbox( 'hostaway_secret_key_value'::bytea, (SELECT secret FROM vault.secrets WHERE name = 'hostaway_encryption_key') ) ); -- Decrypt on select (service role only, in FastAPI backend) SELECT organization_id, account_id, pgsodium.crypto_secretbox_open( encrypted_secret_key::bytea, (SELECT secret FROM vault.secrets WHERE name = 'hostaway_encryption_key') )::text AS secret_key FROM hostaway_credentials WHERE organization_id = 123; ``` **Relationships**: - **N:1** with organizations (many credentials can exist for one org, but only one active via UNIQUE constraint) --- ### 4. api_keys **Purpose**: MCP authentication tokens for AI agents (static keys for Claude Desktop) **Source**: Generated via Next.js dashboard API key management page **Requirements**: FR-005 to FR-008, FR-020, FR-024 ```sql CREATE TABLE api_keys ( id BIGSERIAL PRIMARY KEY, organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, key_hash TEXT NOT NULL UNIQUE, -- SHA-256 hash of actual key created_by_user_id UUID NOT NULL REFERENCES auth.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used_at TIMESTAMPTZ, is_active BOOLEAN NOT NULL DEFAULT true, CHECK (length(key_hash) = 64) -- SHA-256 produces 64 hex chars ); -- Indexes CREATE INDEX idx_api_keys_org ON api_keys(organization_id); CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE is_active = true; -- Fast lookup for validation CREATE INDEX idx_api_keys_active ON api_keys(organization_id, is_active) WHERE is_active = true; -- RLS Policy ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users manage own org API keys" ON api_keys FOR ALL USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); -- Policy: Max 5 active API keys per org (enforced via application logic + constraint) CREATE OR REPLACE FUNCTION check_api_key_limit() RETURNS TRIGGER AS $$ BEGIN IF (SELECT COUNT(*) FROM api_keys WHERE organization_id = NEW.organization_id AND is_active = true) >= 5 THEN RAISE EXCEPTION 'Maximum 5 active API keys per organization'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER api_key_limit_trigger BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION check_api_key_limit(); ``` **Validation Rules**: - `key_hash`: SHA-256 hash (64 hex characters), unique across all orgs - `is_active`: Boolean flag, set to false on deletion/regeneration - Max 5 active keys per organization (enforced via trigger) - Original key value **never stored**, only hash (key shown once at generation) **Key Generation Pattern** (in Next.js API route): ```typescript import crypto from 'crypto' // Generate API key const apiKey = crypto.randomBytes(32).toString('hex') // 64 char hex string const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex') // Store hash await supabase.from('api_keys').insert({ organization_id: orgId, key_hash: keyHash, created_by_user_id: userId, is_active: true }) // Return full key ONCE (never stored again) return { api_key: apiKey } ``` **Relationships**: - **N:1** with organizations (many API keys belong to one org) - **N:1** with auth.users (created_by tracks who generated the key) --- ### 5. subscriptions **Purpose**: Stripe subscription reference for billing per organization **Source**: Created when organization completes onboarding and connects Hostaway account **Requirements**: FR-009 to FR-013, FR-025 ```sql CREATE TYPE subscription_status AS ENUM ('active', 'past_due', 'canceled', 'trialing', 'incomplete'); CREATE TABLE subscriptions ( id BIGSERIAL PRIMARY KEY, organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, stripe_subscription_id TEXT NOT NULL UNIQUE, stripe_customer_id TEXT NOT NULL, -- Denormalized for easier Stripe webhook processing current_quantity INT NOT NULL DEFAULT 0 CHECK (current_quantity >= 0), -- Active listing count status subscription_status NOT NULL DEFAULT 'trialing', billing_period_start TIMESTAMPTZ NOT NULL, billing_period_end TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(organization_id) -- One active subscription per org ); -- Indexes CREATE INDEX idx_subscriptions_org ON subscriptions(organization_id); CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id); CREATE INDEX idx_subscriptions_status ON subscriptions(status); -- RLS Policy ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users view own org subscription" ON subscriptions FOR SELECT USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); -- Policy: Only system (webhooks) can update subscription CREATE POLICY "System updates subscriptions" ON subscriptions FOR UPDATE USING (true) -- Service role key can update any subscription WITH CHECK (true); ``` **Validation Rules**: - `stripe_subscription_id`: Unique Stripe subscription ID (format: `sub_xxx`) - `current_quantity`: Non-negative integer (listing count) - `status`: Must be valid subscription status enum - `billing_period_start` < `billing_period_end` - Unique constraint ensures one active subscription per organization **Update Pattern** (via Stripe webhook Edge Function): ```typescript // On invoice.payment_failed await supabase .from('subscriptions') .update({ status: 'past_due' }) .eq('organization_id', orgId) // On customer.subscription.updated await supabase .from('subscriptions') .update({ current_quantity: newQuantity, billing_period_start: periodStart, billing_period_end: periodEnd }) .eq('stripe_subscription_id', subId) ``` **Relationships**: - **1:1** with organizations (one org has one active subscription) --- ### 6. usage_metrics **Purpose**: Aggregated API usage statistics per organization per month **Source**: Updated on every MCP tool invocation (async increment) **Requirements**: FR-014 to FR-016 ```sql CREATE TABLE usage_metrics ( id BIGSERIAL PRIMARY KEY, organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, month_year TEXT NOT NULL CHECK (month_year ~ '^\d{4}-\d{2}$'), -- Format: YYYY-MM total_api_requests INT NOT NULL DEFAULT 0 CHECK (total_api_requests >= 0), unique_tools_used TEXT[] NOT NULL DEFAULT '{}', -- Array of tool names listing_count_snapshot INT NOT NULL DEFAULT 0, -- Snapshot of listing count for this month created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(organization_id, month_year) -- One record per org per month ); -- Indexes CREATE INDEX idx_usage_metrics_org ON usage_metrics(organization_id); CREATE INDEX idx_usage_metrics_month ON usage_metrics(month_year); CREATE INDEX idx_usage_metrics_org_month ON usage_metrics(organization_id, month_year); -- RLS Policy ALTER TABLE usage_metrics ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users view own org usage" ON usage_metrics FOR SELECT USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); ``` **Validation Rules**: - `month_year`: Must match YYYY-MM format (e.g., "2025-10") - `total_api_requests`: Non-negative integer - `unique_tools_used`: Array of tool names (e.g., ["get_properties", "create_listing"]) - Unique constraint ensures one metrics record per org per month **Increment Pattern** (in FastAPI middleware): ```python from datetime import datetime async def track_usage(org_id: int, tool_name: str): month_year = datetime.now().strftime("%Y-%m") # Upsert usage metrics (increment total_api_requests, add tool to array) await supabase.rpc('increment_usage_metrics', { 'org_id': org_id, 'month': month_year, 'tool': tool_name }) # RPC function in database CREATE OR REPLACE FUNCTION increment_usage_metrics( org_id BIGINT, month TEXT, tool TEXT ) RETURNS VOID AS $$ BEGIN INSERT INTO usage_metrics (organization_id, month_year, total_api_requests, unique_tools_used) VALUES (org_id, month, 1, ARRAY[tool]) ON CONFLICT (organization_id, month_year) DO UPDATE SET total_api_requests = usage_metrics.total_api_requests + 1, unique_tools_used = array_append(usage_metrics.unique_tools_used, tool), updated_at = NOW(); END; $$ LANGUAGE plpgsql; ``` **Relationships**: - **N:1** with organizations (many monthly metrics for one org) --- ### 7. audit_logs **Purpose**: Detailed logs of all MCP tool invocations for compliance and debugging **Source**: Written on every MCP tool invocation (async, non-blocking) **Requirements**: FR-021 ```sql CREATE TABLE audit_logs ( id BIGSERIAL PRIMARY KEY, organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, user_id UUID REFERENCES auth.users(id), -- Nullable: API key usage doesn't have user_id tool_name TEXT NOT NULL CHECK (length(tool_name) >= 1), request_params JSONB, -- Tool invocation parameters response_status INT NOT NULL CHECK (response_status >= 100 AND response_status < 600), error_message TEXT, -- Nullable: only set if response_status >= 400 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes CREATE INDEX idx_audit_logs_org ON audit_logs(organization_id); CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC); CREATE INDEX idx_audit_logs_tool ON audit_logs(tool_name); CREATE INDEX idx_audit_logs_status ON audit_logs(response_status) WHERE response_status >= 400; -- Fast lookup for errors -- Partitioning by month (for retention/archival) CREATE TABLE audit_logs_2025_10 PARTITION OF audit_logs FOR VALUES FROM ('2025-10-01') TO ('2025-11-01'); -- RLS Policy ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users view own org audit logs" ON audit_logs FOR SELECT USING ( organization_id IN ( SELECT organization_id FROM organization_members WHERE user_id = auth.uid() ) ); ``` **Validation Rules**: - `tool_name`: Non-empty string (MCP tool name) - `request_params`: JSONB (can be null if no params) - `response_status`: HTTP status code (100-599) - `error_message`: Nullable, only set if response_status >= 400 **Logging Pattern** (in FastAPI middleware): ```python async def log_tool_invocation( org_id: int, tool_name: str, request_params: dict, response_status: int, error_message: str = None ): await supabase.table('audit_logs').insert({ 'organization_id': org_id, 'tool_name': tool_name, 'request_params': request_params, 'response_status': response_status, 'error_message': error_message, 'created_at': datetime.now().isoformat() }) ``` **Relationships**: - **N:1** with organizations (many audit logs for one org) - **N:1** with auth.users (nullable, only set if user-initiated) --- ## Relationships Diagram ``` ┌─────────────────────────┐ │ auth.users │ (Managed by Supabase Auth) │ - id (UUID, PK) │ │ - email │ │ - encrypted_password │ └─────────────────────────┘ │ │ 1:N ▼ ┌─────────────────────────┐ │ organization_members │ │ - organization_id (FK) │──┐ │ - user_id (FK) │ │ │ - role │ │ └─────────────────────────┘ │ │ N:1 ▼ ┌─────────────────────────┐ │ organizations │ │ - id (PK) │ │ - name │ │ - owner_user_id (FK) │ │ - stripe_customer_id │ └─────────────────────────┘ │ │ 1:N ┌──────────────────┼──────────────────┬──────────────────┐ │ │ │ │ ▼ ▼ ▼ ▼ ┌──────────────────┐ ┌──────────────┐ ┌───────────────┐ ┌────────────────┐ │ hostaway_creds │ │ api_keys │ │ subscriptions │ │ usage_metrics │ │ - id (PK) │ │ - id (PK) │ │ - id (PK) │ │ - id (PK) │ │ - org_id (FK) │ │ - org_id(FK) │ │ - org_id (FK) │ │ - org_id (FK) │ │ - account_id │ │ - key_hash │ │ - stripe_id │ │ - month_year │ │ - encrypted_key │ │ - is_active │ │ - quantity │ │ - api_requests │ └──────────────────┘ └──────────────┘ └───────────────┘ └────────────────┘ │ │ 1:N ▼ ┌─────────────────────────┐ │ audit_logs │ │ - id (PK) │ │ - organization_id (FK) │ │ - tool_name │ │ - request_params (JSONB)│ │ - response_status │ └─────────────────────────┘ ``` --- ## File Organization ### New Model Files (Backend - Python/Pydantic) ```python # src/models/organization.py from pydantic import BaseModel, Field from datetime import datetime from typing import Optional class Organization(BaseModel): """Organization entity (tenant)""" id: int name: str = Field(..., min_length=1, max_length=255) owner_user_id: str # UUID from auth.users stripe_customer_id: Optional[str] = None created_at: datetime updated_at: datetime class OrganizationMember(BaseModel): """User-organization membership""" organization_id: int user_id: str # UUID from auth.users role: str = Field(..., pattern="^(owner|admin|member)$") joined_at: datetime class APIKey(BaseModel): """MCP API key (hash only, never store actual key)""" id: int organization_id: int key_hash: str = Field(..., min_length=64, max_length=64) created_by_user_id: str created_at: datetime last_used_at: Optional[datetime] = None is_active: bool = True class HostawayCredentials(BaseModel): """Encrypted Hostaway credentials""" id: int organization_id: int account_id: str encrypted_secret_key: str credentials_valid: bool = True last_validated_at: Optional[datetime] = None created_at: datetime updated_at: datetime class Subscription(BaseModel): """Stripe subscription""" id: int organization_id: int stripe_subscription_id: str stripe_customer_id: str current_quantity: int = Field(..., ge=0) status: str = Field(..., pattern="^(active|past_due|canceled|trialing|incomplete)$") billing_period_start: datetime billing_period_end: datetime created_at: datetime updated_at: datetime class UsageMetrics(BaseModel): """Monthly usage aggregation""" id: int organization_id: int month_year: str = Field(..., pattern=r"^\d{4}-\d{2}$") total_api_requests: int = Field(..., ge=0) unique_tools_used: list[str] = [] listing_count_snapshot: int = Field(..., ge=0) created_at: datetime updated_at: datetime class AuditLog(BaseModel): """MCP tool invocation log""" id: int organization_id: int user_id: Optional[str] = None tool_name: str request_params: Optional[dict] = None response_status: int = Field(..., ge=100, lt=600) error_message: Optional[str] = None created_at: datetime ``` ### Frontend TypeScript Types (Generated from Supabase) ```bash # Generate TypeScript types from Supabase schema supabase gen types typescript --project-id xxx > dashboard/lib/types/database.ts ``` ```typescript // dashboard/lib/types/database.ts (auto-generated) export type Database = { public: { Tables: { organizations: { Row: { id: number name: string owner_user_id: string stripe_customer_id: string | null created_at: string updated_at: string } Insert: { name: string owner_user_id: string stripe_customer_id?: string | null } Update: { name?: string stripe_customer_id?: string | null } } api_keys: { Row: { id: number organization_id: number key_hash: string created_by_user_id: string created_at: string last_used_at: string | null is_active: boolean } // ... Insert/Update types } // ... other tables } } } ``` --- ## Data Model Complete All entities defined with: - ✅ SQL schema with constraints and indexes - ✅ RLS policies for multi-tenant isolation - ✅ Pydantic models (backend) and TypeScript types (frontend) - ✅ Validation rules and business logic - ✅ Relationship mapping - ✅ Encryption patterns (Supabase Vault) **Next**: Generate API contracts (supabase-schema.sql, supabase-rls.sql, dashboard-api.yaml, stripe-webhooks.yaml)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/darrentmorgan/hostaway-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server