Multi-Tenancy
| Field | Value |
|---|---|
| Document ID | ASCEND-SEC-010 |
| Version | 1.0.0 |
| Last Updated | December 19, 2025 |
| Author | Ascend Engineering Team |
| Publisher | OW-KAI Technologies Inc. |
| Classification | Enterprise Client Documentation |
| Compliance | SOC 2 CC6.1/CC6.2, PCI-DSS 7.1/8.3, HIPAA 164.312, NIST 800-53 AC-2/SI-4 |
Reading Time: 8 minutes | Skill Level: Advanced
Overview
ASCEND implements true multi-tenant architecture with PostgreSQL Row-Level Security (RLS) policies. Each organization's data is completely isolated at the database level, ensuring no cross-tenant data access is possible.
Architecture
+---------------------------------------------------------------------------------+
| MULTI-TENANT ARCHITECTURE |
+---------------------------------------------------------------------------------+
| |
| TENANT A TENANT B TENANT C |
| org_id: 1 org_id: 2 org_id: 3 |
| +-------------------+ +-------------------+ +-------------------+ |
| | Users | | Users | | Users | |
| | Agents | | Agents | | Agents | |
| | Actions | | Actions | | Actions | |
| | Policies | | Policies | | Policies | |
| | Audit Logs | | Audit Logs | | Audit Logs | |
| +-------------------+ +-------------------+ +-------------------+ |
| | | | |
| +---------------------------+---------------------------+ |
| | |
| v |
| +-------------------------------------------------------------------------+ |
| | POSTGRESQL ROW-LEVEL SECURITY | |
| | | |
| | Policy: organization_id = current_setting('app.current_organization_id') |
| | | |
| | - Enforced at database level (cannot be bypassed) | |
| | - Applied to ALL queries (SELECT, INSERT, UPDATE, DELETE) | |
| | - Set via authenticated session context | |
| | | |
| +-------------------------------------------------------------------------+ |
| |
+---------------------------------------------------------------------------------+
Row-Level Security
RLS Policy Implementation
-- Enable RLS on table
ALTER TABLE agent_actions ENABLE ROW LEVEL SECURITY;
-- Create isolation policy
CREATE POLICY tenant_isolation_policy ON agent_actions
USING (organization_id = current_setting('app.current_organization_id')::int);
Session Context Setting
# Source: dependencies_api_keys.py:141
# Set organization context for row-level security
db.execute(
text("SET LOCAL app.current_organization_id = :org_id"),
{"org_id": str(api_key_org_id)}
)
SECURITY DEFINER Functions
-- Authentication lookup bypasses RLS (controlled access)
-- Source: alembic/versions/20251209_sec_rls_002_auth_lookup_function.py
CREATE OR REPLACE FUNCTION auth_lookup_api_key(prefix text)
RETURNS TABLE(
id integer,
key_hash text,
salt text,
user_id integer,
organization_id integer,
is_active boolean,
expires_at timestamp
)
LANGUAGE plpgsql
SECURITY DEFINER -- Runs with table owner privileges
AS $$
BEGIN
RETURN QUERY
SELECT ak.id, ak.key_hash, ak.salt, ak.user_id,
ak.organization_id, ak.is_active, ak.expires_at
FROM api_keys ak
WHERE ak.key_prefix = prefix AND ak.is_active = true;
END;
$$;
Organization Isolation
Data Model
# All tenant-scoped tables include organization_id
class AgentAction(Base):
__tablename__ = "agent_actions"
id = Column(Integer, primary_key=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
# ... other fields
class RegisteredAgent(Base):
__tablename__ = "registered_agents"
id = Column(Integer, primary_key=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
# ... other fields
Query Filtering
# Source: security/enterprise_security.py:645
def verify_organization_ownership(
resource_org_id: int,
user_org_id: int,
resource_type: str = "resource"
) -> bool:
"""
Verify a resource belongs to the user's organization.
Raises HTTPException if ownership check fails.
"""
if resource_org_id != user_org_id:
logger.warning(
f"IDOR BLOCKED: User from org {user_org_id} attempted to access "
f"{resource_type} from org {resource_org_id}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: Resource belongs to another organization"
)
return True
Organization Filter Dependency
# Source: dependencies_api_keys.py:550
def get_organization_filter_dual_auth(current_user: dict = Depends(get_current_user_or_api_key)):
"""
Get organization filter for dual-auth routes.
Works with BOTH JWT (admin UI) and API key (SDK) authentication.
Returns the organization_id for multi-tenant data isolation.
"""
organization_id = current_user.get("organization_id")
if organization_id is None:
logger.warning(f"Organization context missing - data isolation NOT enforced")
return organization_id
Tables with Tenant Isolation
| Table | RLS Enabled | Filter Column |
|---|---|---|
users | Yes | organization_id |
agent_actions | Yes | organization_id |
registered_agents | Yes | organization_id |
smart_rules | Yes | organization_id |
api_keys | Yes | organization_id |
audit_logs | Yes | organization_id |
policies | Yes | organization_id |
playbooks | Yes | organization_id |
mcp_server_actions | Yes | organization_id |
API Key Tenant Binding
Key Generation
# Source: routes/api_key_routes.py:250
# API keys are bound to organization at creation
api_key = ApiKey(
user_id=current_user["user_id"],
organization_id=org_id, # Tenant binding
key_hash=key_hash,
key_prefix=key_prefix,
salt=salt,
name=request.name
)
Key Verification
# Source: dependencies_api_keys.py:141
# After verifying API key, set RLS context
db.execute(
text("SET LOCAL app.current_organization_id = :org_id"),
{"org_id": str(api_key_org_id)}
)
# All subsequent queries in this transaction are tenant-isolated
IDOR Prevention
Endpoint Protection
# All tenant-scoped endpoints verify ownership
@router.get("/agents/{agent_id}")
async def get_agent(
agent_id: str,
current_user: dict = Depends(get_current_user_or_api_key),
org_id: int = Depends(get_organization_filter_dual_auth),
db: Session = Depends(get_db)
):
# Query automatically filtered by org_id
agent = db.query(RegisteredAgent).filter(
RegisteredAgent.agent_id == agent_id,
RegisteredAgent.organization_id == org_id
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
return agent
Security Logging
# Log IDOR attempts
if resource.organization_id != current_user.organization_id:
logger.warning(
f"IDOR BLOCKED: User {current_user.email} from org {current_user.organization_id} "
f"attempted to access {resource_type} from org {resource.organization_id}"
)
# Log to audit trail
log_security_event(
event_type="IDOR_ATTEMPT",
user_id=current_user.user_id,
org_id=current_user.organization_id,
details={"target_org_id": resource.organization_id},
risk_level="high"
)
Compliance Mapping
| Standard | Requirement | Implementation |
|---|---|---|
| SOC 2 CC6.1 | Logical access controls | RLS policies |
| PCI-DSS 7.1 | Need-to-know access | Tenant isolation |
| HIPAA 164.312(a)(1) | Access control | Organization binding |
| NIST AC-3 | Access enforcement | Database-level RLS |
Best Practices
1. Always Use Organization Filter
# Good - explicit filter
query = db.query(Model).filter(
Model.organization_id == org_id
)
# RLS provides backup, but explicit filter is best practice
2. Validate Resource Ownership
# Always verify before operations
verify_organization_ownership(
resource_org_id=resource.organization_id,
user_org_id=current_user.organization_id,
resource_type="agent"
)
3. Include Org ID in Audit Logs
# All audit events include organization context
{
"organization_id": org_id,
"user_id": user_id,
"action": "resource.update",
"resource_id": resource_id
}
4. Test Isolation Regularly
# Verify RLS policies are enforced
def test_tenant_isolation():
# User from org 1 should not see org 2 data
user_org1 = authenticate(org_id=1)
result = query_resources(user_org1)
for resource in result:
assert resource.organization_id == 1
Next Steps
- RBAC — Role-based access control
- Data Protection — Encryption and masking
- Audit Compliance — Compliance logging
Document Version: 1.0.0 | Last Updated: December 2025