Last updated 2026-06-29

Learning Platform

This page models a learning platform that serves both B2C learners who buy a single course and companies who buy seats that grant a whole roster access. One engine, one set of namespaces: a course, cohort, or lesson is a resource; a company is a TEAM workspace; a class or cohort is a group; instructors are editors and learners are viewers. You will see the exact tuples and Check answers for enrolling, instructor grading access, and company-admin oversight.

The model

  • Course / lesson → resource. A lesson is parented to its course, so course-level grants cascade to every lesson.
  • Instructor → editor; learner → viewer. resource.viewer unions computed(editor), so every instructor is automatically a viewer too.
  • Company → workspace (TEAM); class / cohort → group. Seat-holders are workspace members; a cohort is a reusable membership set granted on a course in one tuple.

B2C · a solo learner enrolls

An individual buyer needs no organization. On first ListWorkspaces the service auto-provisions their PERSONAL workspace; it is closed and never used to share course content. Enrollment is a single direct grant — resource:course#viewer@user — the this leaf of the viewer rule, with no parent workspace.

enroll a B2C learner
curl -X POST http://localhost:8080/workspace.v1.AuthzService/WriteRelationTuples \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{
"updates": [
{"op":"OP_INSERT","tuple":{"namespace":"resource","object_id":"course-go","relation":"viewer","subject":{"user_id":"jo"}}}
]
}'
enrollment tuple
resource:course-go#viewer@user:jo # jo bought it personally

The gate the product checks before streaming a lesson — lessons inherit course access via parent:

lesson inherits from course
resource:lesson-1#parent@resource:course-go # lesson belongs to the course
can jo watch lesson 1?
curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"resource","object_id":"lesson-1","relation":"viewer","subject_user_id":"jo"}'
{"allowed": true}

The viewer rule's tupleToUserset(parent, member) does not match here (the course's parent is another resource, not a workspace), but computed(editor) and the course's own viewer grant flow through course → lesson, so jo is allowed.

B2B · a company buys seats

A company is a TEAM workspace. The platform backend creates it and adds each seat-holder as a memberacting_user_id is required on every management call.

provision the company and a seat
# create the company workspace
curl -X POST http://localhost:8080/workspace.v1.WorkspaceService/CreateWorkspace \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"display_name":"BigCo","slug":"bigco","acting_user_id":"bigco-admin"}'
# fill a seat (ROLE_OWNER is rejected here; owner is fixed at creation)
curl -X POST http://localhost:8080/workspace.v1.WorkspaceService/AddMember \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"workspace_id":"bigco","user_id":"employee-1","role":"ROLE_MEMBER","acting_user_id":"bigco-admin"}'

Grant the catalogue to the company by parenting each course to the workspace. Now every seat-holder is a viewer by inheritance — adding a seat is one AddMember call and it grants the whole catalogue at once.

company + catalogue tuples
workspace:bigco#owner@user:bigco-admin
workspace:bigco#member@user:employee-1
workspace:bigco#member@user:employee-2
resource:course-go#parent@workspace:bigco # bigco's seats can view it
can the seat-holder view the course?
curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"resource","object_id":"course-go","relation":"viewer","subject_user_id":"employee-1"}'
{"allowed": true}

The viewer rule's tupleToUserset(parent, member) walks the course's parent to workspace:bigco, evaluates member, and allows. The same course object thus serves both a seat-based company and a one-off B2C buyer: inheritance for the roster, a direct grant for the individual.

Cohorts and instructor grading access

A class or cohort is a group — reusable across the courses it touches. The instructor is granted editor directly (which makes them a viewer too); the cohort group is granted viewer so the whole roster sees the material.

instructor + cohort tuples
# the cohort, and a member of it
group:cohort-2026#member@user:learner-pat
# instructor edits (grades) the course; cohort views it
resource:course-go#editor@user:instructor-sam
resource:course-go#viewer@group:cohort-2026#member

Grading writes need editor; the product checks it before accepting a grade. Because editor unions computed(owner) and the direct this grant, instructor-sam is allowed:

may the instructor grade?
curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"resource","object_id":"course-go","relation":"editor","subject_user_id":"instructor-sam"}'
{"allowed": true}

A cohort learner, by contrast, returns {"allowed": false} for editor but {"allowed": true} for viewer — they can watch, not grade.

Company-admin oversight

Company admins need to see what their seats can access without an explicit per-course grant. Because resource.editor unions tupleToUserset(parent, admin), any workspace:bigco#admin can edit every course parented to the company — oversight is inherited from the workspace role, not granted course by course.

company admin tuple
workspace:bigco#admin@user:program-manager # an L&D admin
can the L&D admin oversee the course?
curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"resource","object_id":"course-go","relation":"editor","subject_user_id":"program-manager"}'
{"allowed": true}

To audit a course's full reach, call Expand(resource, course-go, viewer) — the tree unions the direct grants, the editor subtree (instructors), the cohort group's member userset, and the parent workspace's member set. See Check & Expand.

One model, both audiences

The decision is always Check(resource, <course>, viewer, <user>) — the product never branches on B2C versus B2B. To isolate one large customer's data, give them their own project_id shard (see Projects & Multi-Tenancy); otherwise one B2C product is one project with many personal workspaces. For personal-vs-team semantics, see Workspaces & Groups.