| name | content-access |
| description | Use when touching any endpoint/handler/query/event/migration involving user-gated content (courses, materials, articles, posts, files, etc.) — creating or editing endpoints that return or mutate such content, adding new content entity types, changing access-level/enrollment logic, debugging 401/403 or missing lock icons, writing raw SQL that touches gated tables, or resyncing the entitlements cache. |
Content Access (Entitlements)
The contract for who can see what content on the platform. Every feature that reads or writes gated content MUST respect this contract. Breaking it = leaking paid content or locking users out of content they paid for.
Core principle: source-of-truth DB is authoritative, derived cache (Redis SET intersection / equivalent) is the decision engine.
resource-access:{type}:{id} — tags required to access the resource (written by the content service on mutation).
user-grants:{userId} — tags the user currently holds (written by the progress / access service on enrollment / grant mutation).
- Non-empty intersection → grant. Empty resource SET → fail-closed (deny). Admin → always grant.
Three-Tier Model (every endpoint handles all three)
- Endpoint permission — who can CALL it? →
.RequirePermissions(...) / .RequireAnyRole(...) / explicit AllowAnonymous
- Ownership — is it their resource? →
_user.IsOwnerOrAdmin(entity.AuthorId) for mutations on author-owned entities
- Entitlement — can they see THIS specific content? →
IEntitlementChecker.CheckAccessAsync(...) for gated reads / writes
Missing any tier = security bug. Reviewing a PR that adds an endpoint? Ask all three aloud.
Typical Access Matrix (adapt levels to your project)
A common four-tier model. Yours may use different names — keep the same structure:
| AccessType | Anonymous | Authenticated (no enrollment) | Trial enrollment | Full enrollment | Admin |
|---|
PUBLIC | ✅ | ✅ | ✅ | ✅ | ✅ |
REGISTERED | ❌ | ✅ | ✅ | ✅ | ✅ |
FREE | ❌ | ❌ | ✅ | ✅ | ✅ |
ENROLLED | ❌ | ❌ | ❌ | ✅ | ✅ |
Note: FREE is NOT public when your model has a trial tier — FREE means "free with enrollment". Anonymous users still see a lock.
Cache Tag Vocabulary (example for Redis SINTER pattern)
Resource tags (written by content service) and user grants (written by access service) share the same vocabulary.
| Tag | Meaning |
|---|
access:public | PUBLIC resource (explicit marker, not empty) |
authenticated | Any logged-in user |
course:{guid} | Full enrollment on this course / plan |
course:{guid}:trial | Trial or full enrollment on this course / plan |
enrolled:unassigned | Orphan FREE/ENROLLED (no parent) — sentinel |
A tag-builder maps AccessType → required tags:
PUBLIC → ["access:public"]
REGISTERED → ["authenticated"]
FREE → ["course:{id}:trial", ...] per parent
ENROLLED → ["course:{id}", ...] per parent
- FREE/ENROLLED with zero parents →
["enrolled:unassigned"] (closed, logged as warning)
Full enrollment writes BOTH course:{id} and course:{id}:trial → full user sees all FREE and ENROLLED. Trial writes only course:{id}:trial.
Cache Key Schema
resource-access:{type}:{resourceId} # SET of required tags (content service owns)
user-grants:{userId} # SET of grant tags (access service owns)
type ∈ course | material | issue | quiz | collection | ... — define your set of resource types in a single constants file. Adding a new content entity = adding a new constant here.
Endpoint Rules — What Each Endpoint Type MUST Check
| Endpoint type | Permission | Ownership | Entitlement | Notes |
|---|
| Detail read (returns content body) | Auth or Anon | — | YES | Material / Issue / Collection detail, Comment reads |
| Metadata-only feed (title, preview ≤280ch) | Auth or Anon | — | optional | May return ALL published; enrich with isAccessible + lockReason |
| Content-returning list (full body) | Auth or Anon | — | YES (SQL filter or per-item) | See anti-pattern below |
| Author mutation (create/update/delete) | Permission | YES | — | Always IsOwnerOrAdmin(authorId) |
| Student write (submit / view / complete) | Permission | — | YES | Trial user must NOT submit ENROLLED items |
| Admin-only | Role check | — | — | Service-to-service, user management |
| Global search | Auth or Anon | — | per-hit lockReason | Returns ALL hits; lock icon via LockReasonResolver |
Canonical single source of truth for lock reasons: LockReasonResolver.cs (your project's path) — values like anonymous | trial_required | standard_required | not_enrolled. Don't invent new ones; the frontend switches on these exact strings.
Implementation Patterns
Single-item check (write or detail)
AccessDecision decision = await _entitlementChecker.CheckAccessAsync(
_user.ToAccessSubject(),
ResourceTypes.MATERIAL,
command.MaterialId,
ct);
if (!decision.IsGranted)
return ContentErrors.AccessDenied();
Detail endpoints may short-circuit: if (isAuthor || (isPublic && isPublished)) return entity; before calling the cache — avoids round-trip for the common case.
Batch check + LockReasonResolver (feeds)
var (entitlements, enrolledCourses) = await (
_entitlementChecker.CheckAccessBatchAsync(subject, ResourceTypes.MATERIAL, materialIds, ct),
_entitlementChecker.GetUserEnrolledCourseIdsAsync(_user.UserId, includeTrial: true, ct));
foreach (var row in rows)
{
var tags = _contentAccessTagBuilder.Build(row.AccessType, row.CourseId, row.CourseIds);
var result = LockReasonResolver.Resolve(tags, userGrants, isAuthenticated: _user.UserId != Guid.Empty);
row.IsAccessible = result.IsAccessible;
row.LockReason = result.LockReason;
}
One cache round-trip for user grants + batch SMEMBERS for resource tags. Never loop CheckAccessAsync per row.
Ownership check (mutations)
if (!_user.IsOwnerOrAdmin(material.AuthorId))
return GeneralErrors.Forbidden();
IsOwnerOrAdmin is defined on UserScopedData — admin bypass built in. Never write if (material.AuthorId != _user.UserId) manually; you'll miss admin.
Admin bypass
Built into the entitlement checker at the top of CheckAccessAsync / CheckAccessBatchAsync — returns Granted(ADMIN_OR_AUTHOR) unconditionally. Do not duplicate the admin check in use-cases; trust the interface.
SQL-level AccessType filtering (content-returning lists)
query = query.Where(m => m.AccessType == AccessType.PUBLIC && m.Status == PublishStatus.PUBLISHED);
var enrolledCourseIds = await _entitlementChecker.GetUserEnrolledCourseIdsAsync(
_user.UserId, includeTrial: true, ct);
query = query.Where(m =>
m.AccessType == AccessType.PUBLIC ||
m.AccessType == AccessType.REGISTERED ||
(m.AccessType == AccessType.FREE && m.CourseIds.Any(c => enrolledCourseIds.Contains(c))) ||
(m.AccessType == AccessType.ENROLLED && m.CourseIds.Any(c => standardCourseIds.Contains(c))));
IDOR anti-pattern (forbidden): accepting courseIds from the client and trusting them as proof of enrollment. Always resolve enrollments server-side.
Adding a New Content Entity — Checklist
Before shipping the first endpoint that returns it:
- Add
ResourceTypes constant in your shared constants file. Update the enum-name lookup map too.
- Domain:
AccessType field — enum stored as string (no default value). Set in constructor. If the entity can be linked to parents (courses / plans), keep the parent list queryable.
ContentAccessTagBuilder — the existing method works for any entity whose access depends on AccessType × parentIds. If the entity has different rules, extend the builder, don't invent tags ad-hoc.
- Integration events — define
{entity}.created, {entity}.access_changed, {entity}.hard_deleted. Publish from the write-side via the domain → outbox pipeline.
- Sync handlers —
Sync{Entity}AccessOnCreationHandler, Sync{Entity}AccessToCacheHandler (on access_changed), hard-delete handler that calls IResourceAccessWriter.DeleteAsync(...).
- Endpoints — detail calls
CheckAccessAsync, feeds call CheckAccessBatchAsync + LockReasonResolver, writes call CheckAccessAsync + IsOwnerOrAdmin.
- Resync CLI — extend the resyncer so
resync-access-tags also rebuilds your new resource type. Non-negotiable for disaster recovery.
- Search indexing — if the entity is searchable, index
required_access_tags and run the hit through LockReasonResolver.
- Integration tests — use
FakeEntitlementChecker with GrantAll() + DenyAll() + SetDecision(...) paths. Cover anon, authenticated-no-enrollment, trial, full, admin.
- Docs — update root
CLAUDE.md matrix section and the service CLAUDE.md if the entity introduces a new access policy.
Keeping Data Consistent (Cache ↔ DB)
The cache stays correct only because event handlers run on every write. Skipping the write-path = cache drifts from truth.
| Mutation | Event | Handler writes to |
|---|
| Material/Issue/Collection created | material.created / … | resource-access:{type}:{id} via SetTagsAsync |
| AccessType or parent-link changed | material.access_changed / … | rebuilt via ContentAccessTagBuilder |
| Material/Issue/Collection hard-deleted | material.hard_deleted / … | DeleteAsync — SET removed |
| Enrollment created (trial / full) | course_enrollment.created | user-grants:{userId} — adds parent tags |
| Trial → Full upgrade | course_enrollment.upgraded | adds full tag |
| Enrollment revoked / parent hard-deleted | course_enrollment.* / course.hard_deleted | removes parent tags |
Golden rule — never write raw SQL into the gated tables (<content_schema>.*, <progress_schema>.*) on production. It bypasses handlers → cache never updates → users get 401/403 on content they should see. Seed and import ONLY through domain handlers (admin endpoint, seed CLI).
If you must do a bulk data migration, mirror the cache write in the same migration (or follow it with a resync).
What To Do When It Breaks
Symptom: user gets 401/403 on content they should see
- Is cache up?
docker ps | grep redis (or equivalent). Fail-closed semantics — cache down = everything denied.
- Compare SETs.
SMEMBERS resource-access:material:{id} — expected: matches ContentAccessTagBuilder output for that material's AccessType + parents. Empty SET = handler never ran.
SMEMBERS user-grants:{userId} — expected: parent tags for every active enrollment.
- SINTER of the two = the decision. No intersection → deny.
- Handler logs. Grep content service and access service for the resource id / user id around the time of the write. Missing log = handler didn't fire → check broker dead-letter queue and outbox table.
- Admin test. Call the same endpoint as admin. If admin works → it's an entitlement issue, not a bug in the endpoint logic.
Symptom: cache lost (flush, new cluster, corrupted AOF)
Two idempotent CLI commands rebuild everything from DB:
docker exec content-service dotnet ContentService.Web.dll resync-access-tags
docker exec access-service dotnet AccessService.Web.dll resync-user-grants
Safe to run any time — not just after DR.
Symptom: users got content they shouldn't see
Rare — usually means a handler wrote the wrong tags. Check: was the entity FREE/ENROLLED with zero parents at the moment of access_changed? → enrolled:unassigned was expected. Re-run resync-access-tags.
Symptom: accessType=FREE and user has a full enrollment but still gets locked
- Full enrollment must write
course:{id}:trial in addition to course:{id}. Check user-grants SET — if only course:{id} is there, the trial tag was not added → fix the enrollment handler, then run resync-user-grants.
Symptom: search or feed shows isAccessible=true for a locked item (or vice versa)
LockReasonResolver.Resolve must receive the same tags that were written to the cache. Mismatch = bug in the builder call site. Compare the requiredAccessTags passed to the resolver against SMEMBERS resource-access:....
Tests
- Use
FakeEntitlementChecker (in your test-support package). Register it in the integration test factory to replace IEntitlementChecker.
- API:
GrantAll(), DenyAll(), DenyResourceType(type), SetDecision(type, id, decision), EnrollFull(userId, courseId), EnrollTrial(...), Reset().
- Every new access-gated endpoint needs tests for anon, authenticated-no-enrollment, trial, full, admin — at minimum the states that should behave differently.
Red Flags — Stop and Re-check
| Thought / pattern | Why it's wrong |
|---|
| "It's just a list, I'll skip the entitlement check" | If the list returns body / content, it's content-returning → SQL filter or per-item check required. |
"Accept courseIds from the client — saves a query" | IDOR. Attacker passes unowned IDs. Always resolve enrollments server-side. |
"AccessType.FREE means public, right?" | No. In a trial-tier model FREE requires trial or full enrollment. |
"I'll add an isAdmin check in the handler" | The entitlement checker already bypasses for admin. Don't duplicate. |
| "I'll raw-SQL the seed data, it's just initial content" | On prod this bypasses handlers → cache never syncs → 401 for everyone. Domain-only writes. |
"The entity has no parent — I'll use PUBLIC tag for FREE" | Orphan FREE/ENROLLED must get enrolled:unassigned (closed). ContentAccessTagBuilder handles it. |
| "Cache is empty for this material — I'll default to allow" | Fail-CLOSED. Empty SET = deny. Rebuild via resync-access-tags. |
"I'll just compare entity.AuthorId == _user.UserId" | Misses admin. Use _user.IsOwnerOrAdmin(authorId). |
| "I added a new content entity but didn't touch the resyncer" | DR is now broken for that entity. Extend the resyncer. |
"I invented lockReason: 'premium_required'" | Frontend switches on the four canonical strings. Extend LockReasonResolver if you really need a new reason. |