OmniGit Example
Two agent scoping patterns with Agent + Codebase objects
OmniGit Example
This example walks through a complete permission setup for OmniGit — a code hosting platform where both users and AI agents interact with codebases.
Scenario
OmniGit has four object types:
| Object | Type | Purpose |
|---|---|---|
User | Built-in subject | Developers |
Tenant | Built-in container | An organization owning repositories |
Agent | Custom subject | LLM-powered AI agent |
Codebase | Custom resource | A git repository |
There are two ways to scope an agent:
- Codebase Scoped Agent — permissions granted directly on a specific codebase. The agent can only act on that repo.
- Tenant Wide Agent — permissions granted on the tenant, cascading to all codebases through
traverse(). The agent can act on every repo in the org.
Relations Graph
┌──────────┐
│ User │
└────┬─────┘
│ can_edit (direct)
│
▼
┌──────────────────────┐ tenant ┌──────────┐
│ Codebase │──────────────────▶│ Tenant │
│ (git repository) │ │ (org) │
└──────────────────────┘ └──────────┘
▲ ▲ ▲
│ │ │
│ │ can_push (tenant-wide) │ can_edit (tenant-wide)
│ └───────────────────────────────────┤
│ │
can_push (scoped) │ can_edit (tenant-wide)
│ │
┌────┴─────┐ ┌──────┴──────┐
│ Agent │ │ Agent │
│ (scoped) │ │ (tenant-wide)│
└──────────┘ └─────────────┘Definitions
Agent
import { Context, Namespace } from "./types";
export class Agent implements Namespace {}The Agent namespace is empty — it exists only as a subject type, the same way User does. It has no relations of its own; it is the target of relations on other objects.
Codebase
import { Agent, Context, Namespace, Tenant, User } from "./types";
export class Codebase implements Namespace {
related: {
/** @hidden — link to owning org for tenant-wide traversal */
tenant: Tenant[];
/** @hidden — codebase owner inherits permissions */
owner: User[];
/**
* @group Codebase Access
* @displayName Edit Code
* @role owner
* @role developer
*/
can_edit: (User | Agent)[];
/**
* @group Codebase Access
* @displayName Push Changes
* @role owner
* @role developer
*/
can_push: (User | Agent)[];
/**
* @group Codebase Access
* @displayName View Code
* @role owner
* @role developer
* @role viewer
*/
can_view: (User | Agent)[];
/**
* @group Codebase Access
* @displayName Delete Codebase
* @role owner
*/
can_delete: (User | Agent)[];
};
permits = {
edit: (ctx: Context): boolean =>
this.related.can_edit.includes(ctx.subject) ||
this.related.owner.includes(ctx.subject) ||
this.related.tenant.traverse((t) =>
t.related.can_edit.includes(ctx.subject)
),
push: (ctx: Context): boolean =>
this.related.can_push.includes(ctx.subject) ||
this.related.owner.includes(ctx.subject) ||
this.related.tenant.traverse((t) =>
t.related.can_push.includes(ctx.subject)
),
view: (ctx: Context): boolean =>
this.related.can_view.includes(ctx.subject) ||
this.related.owner.includes(ctx.subject) ||
this.related.tenant.traverse((t) =>
t.related.can_view.includes(ctx.subject)
),
delete: (ctx: Context): boolean =>
this.related.can_delete.includes(ctx.subject) ||
this.related.owner.includes(ctx.subject) ||
this.related.tenant.traverse((t) =>
t.related.can_delete.includes(ctx.subject)
),
};
}The permits block checks three paths for every action:
- Direct — was the permission granted on this specific codebase?
- Owner — does the subject own this codebase?
- Tenant — was the permission granted at the tenant level?
The owner.includes() check works because the owner relation stores User subjects directly. If the subject matches one of the owners, the check passes — no traversal needed.
Agent Scoping Patterns
Codebase Scoped Agent
A codebase-scoped agent gets permissions directly on a specific codebase. It can only act on repos it has been explicitly granted access to.
Agent ──can_push──▶ Codebase#cb-123Grant:
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Codebase',
object: 'cb-123',
relation: 'can_push',
subjectId: 'agent-pr-reviewer',
subjectNamespace: 'Agent',
},
});
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Codebase',
object: 'cb-123',
relation: 'can_view',
subjectId: 'agent-pr-reviewer',
subjectNamespace: 'Agent',
},
});_, _, err := client.CreateRelationship(ctx).CreateRelationshipRequest(omnibase.CreateRelationshipRequest{
Namespace: "Codebase",
Object: "cb-123",
Relation: "can_push",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-pr-reviewer",
},
}).Execute()
_, _, err = client.CreateRelationship(ctx).CreateRelationshipRequest(omnibase.CreateRelationshipRequest{
Namespace: "Codebase",
Object: "cb-123",
Relation: "can_view",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-pr-reviewer",
},
}).Execute()Check:
// Can agent-pr-reviewer push to cb-123?
const { data } = await permissionsApi.checkPermission({
checkPermissionRequest: {
namespace: 'Codebase',
object: 'cb-123',
relation: 'push',
subjectId: 'agent-pr-reviewer',
subjectNamespace: 'Agent',
},
});
// data.allowed → trueresult, _, err := client.CheckPermission(ctx).CheckPermissionRequest(omnibase.CheckPermissionRequest{
Namespace: "Codebase",
Object: "cb-123",
Relation: "push",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-pr-reviewer",
},
}).Execute()
// result.Data.Allowed → trueEvaluation path:
- Is
agent-pr-reviewerincan_pushofCodebase#cb-123? → Yes (direct grant) → ALLOWED
// Can agent-pr-reviewer push to cb-456? (different repo, no grant)
// data.allowed → falseEvaluation path:
- Is
agent-pr-reviewerincan_pushofCodebase#cb-456? → No - Follow
owner→ no match - Follow
tenant→ agent has no tenant-level grant → DENIED
Tenant Wide Agent
A tenant-wide agent gets permissions on the tenant itself. Because permits.push includes tenant.traverse(), the agent can push to every codebase under that tenant.
Agent ──can_push──▶ Tenant#org-abc ────tenant───▶ Codebase#cb-123
Codebase#cb-456
Codebase#cb-789Grant:
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Tenant',
object: 'org-abc',
relation: 'can_push',
subjectId: 'agent-ci-bot',
subjectNamespace: 'Agent',
},
});
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Tenant',
object: 'org-abc',
relation: 'can_view',
subjectId: 'agent-ci-bot',
subjectNamespace: 'Agent',
},
});_, _, err := client.CreateRelationship(ctx).CreateRelationshipRequest(omnibase.CreateRelationshipRequest{
Namespace: "Tenant",
Object: "org-abc",
Relation: "can_push",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-ci-bot",
},
}).Execute()
_, _, err = client.CreateRelationship(ctx).CreateRelationshipRequest(omnibase.CreateRelationshipRequest{
Namespace: "Tenant",
Object: "org-abc",
Relation: "can_view",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-ci-bot",
},
}).Execute()Now agent-ci-bot can push to and view every codebase in Tenant#org-abc.
Check:
// Can agent-ci-bot push to cb-123 (a repo in org-abc)?
const { data } = await permissionsApi.checkPermission({
checkPermissionRequest: {
namespace: 'Codebase',
object: 'cb-123',
relation: 'push',
subjectId: 'agent-ci-bot',
subjectNamespace: 'Agent',
},
});
// data.allowed → trueresult, _, err := client.CheckPermission(ctx).CheckPermissionRequest(omnibase.CheckPermissionRequest{
Namespace: "Codebase",
Object: "cb-123",
Relation: "push",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-ci-bot",
},
}).Execute()
// result.Data.Allowed → trueEvaluation path:
- Is
agent-ci-botincan_pushofCodebase#cb-123? → No (no direct grant) - Follow
owner→ no match - Follow
tenant→ the codebase belongs toTenant#org-abc - Is
agent-ci-botincan_pushofTenant#org-abc? → Yes (tenant-wide grant) → ALLOWED
// Same check for cb-456:
// data.allowed → true (same tenant, same traversal)Comparison
| Aspect | Codebase Scoped Agent | Tenant Wide Agent |
|---|---|---|
| Grant target | Specific codebase | Tenant |
| Scope | Single repo | All repos in the org |
| Grant calls | One per codebase | One for the whole org |
| Use case | Dedicated PR reviewer per repo | CI bot that needs access to everything |
| Traversal used | Direct relation only | tenant.traverse() fallback |
Practical Workflow
Creating a Codebase
When a user creates a new codebase, the backend sets up ownership:
// On codebase creation, set the owner
keto.CreateRelationTuple(ctx, KetoRelationTuple{
Namespace: "Codebase",
Object: codebaseID,
Relation: "owner",
Subject: fmt.Sprintf("User:%s", userID),
})
// Link to the tenant for traversal
keto.CreateRelationTuple(ctx, KetoRelationTuple{
Namespace: "Codebase",
Object: codebaseID,
Relation: "tenant",
Subject: fmt.Sprintf("Tenant:%s", tenantID),
})// On codebase creation, set the owner
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Codebase',
object: codebaseId,
relation: 'owner',
subjectId: userId,
subjectNamespace: 'User',
},
});
// Link to the tenant for traversal
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Codebase',
object: codebaseId,
relation: 'tenant',
subjectId: tenantId,
subjectNamespace: 'Tenant',
},
});The tenant relation is what enables tenant-wide agents — it creates the edge that traverse() follows.
Adding a Scoped Agent
// Deploy a PR-review agent scoped to a single repo
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Codebase',
object: 'cb-123',
relation: 'can_view',
subjectId: 'agent-pr-review',
subjectNamespace: 'Agent',
},
});// Deploy a PR-review agent scoped to a single repo
_, _, err := client.CreateRelationship(ctx).CreateRelationshipRequest(omnibase.CreateRelationshipRequest{
Namespace: "Codebase",
Object: "cb-123",
Relation: "can_view",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-pr-review",
},
}).Execute()Adding a Tenant-Wide Agent
// Deploy a CI bot that works across all repos
await permissionsApi.createRelationship({
createRelationshipRequest: {
namespace: 'Tenant',
object: 'org-abc',
relation: 'can_push',
subjectId: 'agent-ci-bot',
subjectNamespace: 'Agent',
},
});// Deploy a CI bot that works across all repos
_, _, err := client.CreateRelationship(ctx).CreateRelationshipRequest(omnibase.CreateRelationshipRequest{
Namespace: "Tenant",
Object: "org-abc",
Relation: "can_push",
SubjectSet: omnibase.SubjectSetRequest{
Namespace: "Agent",
Object: "agent-ci-bot",
},
}).Execute()Checking in Middleware
Use the standard permission check pattern in your backend handlers. The subject namespace is resolved automatically from the auth context:
// Handler that needs push permission
func PushToCodebase(c *gin.Context) {
codebaseID := c.Param("codebase_id")
if err := keto.CheckPermission(c, "Codebase", codebaseID, "push"); err != nil {
c.JSON(403, gin.H{"error": "forbidden"})
return
}
// Agent or User has push — proceed
}// Handler that needs push permission
async function pushToCodebase(req, res) {
const codebaseId = req.params.codebase_id;
const { data } = await permissionsApi.checkPermission({
checkPermissionRequest: {
namespace: 'Codebase',
object: codebaseId,
relation: 'push',
subjectId: req.userId,
subjectNamespace: 'User',
},
});
if (!data.data.allowed) {
return res.status(403).json({ error: 'forbidden' });
}
// Agent or User has push — proceed
}The Keto engine evaluates the OPL permits.push logic, which includes all three traversal paths. Whether the permission was granted directly, through ownership, or through the tenant — the check resolves the same way.