Owliver could offer neither create. The Create Position flow worked and no chip anywhere suggested it, because the chip row is entirely the backend's static catalogue and no intent in it wrote anything. The gap was never in the frontend's trigger matching — every phrasing already routed. `employee_roles` is the supply side of `job_postings`. A posting is what the ORGANIZATION needs filled; this is what a WORKER says they do. They share a vocabulary and almost nothing else: "3 years" on a posting is a minimum an applicant must clear, and the same words here are what the person has. There is deliberately no foreign key between them — supply and demand already meet through `job_applications`, which carries the funnel, the interview and the outcome, and a second weaker link would disagree with it the first time somebody withdrew. NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company position" reads like it needs a client record. `organizations` is the TENANT — absent from the resource table, absent from the policy map, written only by the seeder — so creating a row there from a chat flow would provision a new tenant, and the position would carry an org_id the operator's session cannot see. The operator could never view the record they just created. That breaks I5 and I1 to add a feature nobody asked for. The client stays free text on the posting, per blueprint decision D2, and the flow simply offers the clients this organization already staffs for as chips. No schema change, no endpoint change. Create is operators-only, and that is an I1 decision rather than a deferral. The worker is named explicitly on the row and is deliberately NOT derived from the session, because an operator recording a role on somebody's behalf is the whole point of the flow. Granting talent the same Create would let a talent caller write a role under any worker_email in the tenant — the attribution hole Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate, which is in place now so the grant is one line when a talent console exists. `created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's Derived list. Both are required and the pairing is easy to miss: Derived fills the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly so a request body cannot set it in the first place. Without it, TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not by reading. The two catalogue intents carry PHRASE terms only. A bare "position" or "role" term scores 10, the same as every reading on that page, and wins the tie on declaration order — so a create chip would have arrived by evicting `positions-attention` from the exact ordered result TestPositionsSuggestions asserts. An offer to create something must not displace the reading a person actually asked for. Neither declares a Subject, on the precedent of `position-spec-steps`: a Subject would let the bare query "summarize" match through matchShape and survive filterOnTopic. Neither declares a Signal, so an empty composer still reports what the organization needs rather than proposing paperwork. Chip text is the coupling with nothing else holding it together: no page context declares `capabilities`, so every server suggestion dispatches as its own TEXT and is answered by whichever skill's trigger that text matches. A renamed chip would open nothing, silently. Asserted on the frontend side. The down migration drops `employee_role_status` and keeps `english_level`, which is shared with job_postings.english_required and job_applications.english_level. Rolled back and re-applied against the database to prove it, not asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
351 lines
14 KiB
Go
351 lines
14 KiB
Go
package domain
|
|
|
|
// Authorization policy: who may perform which operation on which resource, and
|
|
// which rows they may see when they get there.
|
|
//
|
|
// This file is hand-written and `resources_gen.go` is generated, which is the
|
|
// whole reason they are separate. Regenerating the descriptors from the live
|
|
// schema must never silently drop an access rule, and a column appearing in the
|
|
// database must never grant anybody anything by accident.
|
|
//
|
|
// Three properties hold here by construction:
|
|
//
|
|
// - DENY BY DEFAULT. A resource with no policy permits nothing, to anyone. A
|
|
// resource added to the schema tomorrow is unreachable until somebody
|
|
// writes down who may reach it. TestEveryResourceHasAPolicy makes the
|
|
// omission loud rather than silent.
|
|
// - ROLE IS users.role, ALWAYS. Never account_type — which the user can
|
|
// change on themselves through PATCH /me — and never anything read from a
|
|
// request body, a header or the browser.
|
|
// - OWNERSHIP IS A SQL PREDICATE, NOT A FILTER. TalentScope describes a WHERE
|
|
// clause the repository adds beside the organization scope. Rows a talent
|
|
// user may not see are never fetched, so they cannot leak through a count,
|
|
// a total or a bug in a later loop.
|
|
//
|
|
// Authorization is checked in the handler, before any query runs, and answers
|
|
// 403. Organization and ownership are predicates, so a row outside them is
|
|
// simply absent and answers 404 — the caller cannot tell "exists but not yours"
|
|
// from "does not exist", which is the point.
|
|
|
|
// Role is the authorization authority. It mirrors the users_role_check
|
|
// constraint in migration 000001 and there are deliberately no others.
|
|
type Role string
|
|
|
|
const (
|
|
RoleAdmin Role = "admin"
|
|
RoleEmployer Role = "employer"
|
|
RoleTalent Role = "talent"
|
|
)
|
|
|
|
// ParseRole converts a stored users.role into a Role, reporting whether it is
|
|
// one this API recognises. An unrecognised value authorizes nothing.
|
|
func ParseRole(s string) (Role, bool) {
|
|
switch Role(s) {
|
|
case RoleAdmin:
|
|
return RoleAdmin, true
|
|
case RoleEmployer:
|
|
return RoleEmployer, true
|
|
case RoleTalent:
|
|
return RoleTalent, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
/* ── Row visibility ─────────────────────────────────────────────────────── */
|
|
|
|
// ScopeKind is how a resource decides which rows a talent user may see.
|
|
type ScopeKind int
|
|
|
|
const (
|
|
// ScopeNone: no extra predicate. Every row in the organization is visible.
|
|
ScopeNone ScopeKind = iota
|
|
// ScopeUserID: Column = the authenticated user's id.
|
|
ScopeUserID
|
|
// ScopeEmail: Column = the authenticated user's email.
|
|
//
|
|
// Used where the schema ties a row to a person by email string rather than
|
|
// by a foreign key — assignments, evidence, shift records, applications,
|
|
// activity. Those columns have no FK (see the Phase 3D audit, F-05), so the
|
|
// write path is what makes this trustworthy: a talent caller never supplies
|
|
// the value, it is derived from the session. See Derived.
|
|
ScopeEmail
|
|
// ScopeOwnApplications: the row references a job application belonging to
|
|
// the authenticated user. Ownership by reference rather than by column —
|
|
// an AI interview names an application, and the application names a person.
|
|
ScopeOwnApplications
|
|
// ScopeActivePostings: visibility rather than ownership. A talent user sees
|
|
// the postings they could apply to, not the organization's drafts, paused
|
|
// roles or closed history.
|
|
ScopeActivePostings
|
|
)
|
|
|
|
// Scope is the predicate applied to a talent caller's rows.
|
|
type Scope struct {
|
|
Kind ScopeKind
|
|
// Column is the column carrying the owner, for ScopeUserID and ScopeEmail.
|
|
// For ScopeOwnApplications it is the column referencing the application.
|
|
// For ScopeActivePostings it is the status column.
|
|
//
|
|
// Required by every kind except ScopeNone: a scope naming a column the
|
|
// resource does not have matches no rows at all, which is the safe
|
|
// direction to fail but is still a bug worth noticing.
|
|
Column string
|
|
}
|
|
|
|
/* ── Server-owned values ────────────────────────────────────────────────── */
|
|
|
|
// DeriveSource names which fact about the caller fills a column.
|
|
type DeriveSource int
|
|
|
|
const (
|
|
DeriveUserID DeriveSource = iota
|
|
DeriveEmail
|
|
DeriveFullName
|
|
DeriveAccountType
|
|
)
|
|
|
|
// Derived is a column the server fills in on insert from the session.
|
|
//
|
|
// Every column named here is also ReadOnly in the descriptors, so a value in a
|
|
// request body is dropped before it reaches SQL. This is the other half: the
|
|
// column still has to be filled, and the only acceptable source is the
|
|
// authenticated identity.
|
|
type Derived struct {
|
|
Column string
|
|
Source DeriveSource
|
|
// TalentOnly restricts the derivation to talent callers.
|
|
//
|
|
// It exists because two different questions wear the same shape. `created_by`
|
|
// and the user_activity columns record WHO ACTED, so they are the session
|
|
// user whoever that is. `worker_profiles.user_id`, `job_applications.email`
|
|
// and `evidence.worker_email` record WHO THE ROW IS ABOUT — and when an
|
|
// admin creates a candidate's profile or logs an application on their
|
|
// behalf, the subject is emphatically not the admin. Deriving those
|
|
// unconditionally would quietly file every candidate's record under the
|
|
// operator who typed it in.
|
|
TalentOnly bool
|
|
}
|
|
|
|
/* ── Policy ─────────────────────────────────────────────────────────────── */
|
|
|
|
// Policy is one resource's access rules.
|
|
//
|
|
// A nil Policy denies everything. An empty role list for an operation denies
|
|
// that operation to everyone, which is how an operation the resource does not
|
|
// support is expressed.
|
|
type Policy struct {
|
|
List []Role
|
|
Get []Role
|
|
Create []Role
|
|
Update []Role
|
|
Delete []Role
|
|
|
|
// TalentScope narrows which rows a talent caller may read or write. It is
|
|
// applied to talent and to nobody else: admin and employer see the whole
|
|
// organization, which is what an operator console is for.
|
|
TalentScope Scope
|
|
|
|
// Derived fills server-owned columns on insert.
|
|
Derived []Derived
|
|
}
|
|
|
|
// Allows reports whether a role may perform an operation.
|
|
//
|
|
// The zero answer is no: a nil policy, an unknown operation and an unlisted
|
|
// role all deny.
|
|
func (p *Policy) Allows(op Op, role Role) bool {
|
|
if p == nil {
|
|
return false
|
|
}
|
|
for _, r := range p.rolesFor(op) {
|
|
if r == role {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Policy) rolesFor(op Op) []Role {
|
|
switch op {
|
|
case OpList:
|
|
return p.List
|
|
case OpGet:
|
|
return p.Get
|
|
case OpCreate:
|
|
return p.Create
|
|
case OpUpdate:
|
|
return p.Update
|
|
case OpDelete:
|
|
return p.Delete
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ScopeFor returns the row predicate that applies to a role. Only talent is
|
|
// scoped; every other role sees the organization.
|
|
func (p *Policy) ScopeFor(role Role) Scope {
|
|
if p == nil || role != RoleTalent {
|
|
return Scope{}
|
|
}
|
|
return p.TalentScope
|
|
}
|
|
|
|
/* ── The table ──────────────────────────────────────────────────────────── */
|
|
|
|
var (
|
|
everyone = []Role{RoleAdmin, RoleEmployer, RoleTalent}
|
|
// operators are the roles that run the organization's hiring and workforce:
|
|
// they see and act on the whole tenant. Talent is not one of them.
|
|
operators = []Role{RoleAdmin, RoleEmployer}
|
|
adminOnly = []Role{RoleAdmin}
|
|
)
|
|
|
|
// policies is the authorization contract, keyed by URL path.
|
|
//
|
|
// Read this table as the answer to "who may call this, and which rows do they
|
|
// get". It is the only place those two questions are answered.
|
|
var policies = map[string]*Policy{
|
|
|
|
// Postings are the organization's shop window. Operators author them;
|
|
// talent sees the ones that are open, and nothing else — not the drafts,
|
|
// not the paused roles, not the closed history.
|
|
"job-postings": {
|
|
List: everyone, Get: everyone,
|
|
Create: operators, Update: operators,
|
|
TalentScope: Scope{Kind: ScopeActivePostings, Column: "status"},
|
|
Derived: []Derived{{Column: "created_by", Source: DeriveUserID}},
|
|
},
|
|
|
|
// An application is written by a person about themselves. Talent may file
|
|
// one and read their own; only operators may move it through the funnel or
|
|
// remove it. A talent caller never supplies the email — it is the session's,
|
|
// which is what makes the read predicate below mean anything.
|
|
"job-applications": {
|
|
List: everyone, Create: everyone,
|
|
Update: operators, Delete: operators,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "email"},
|
|
Derived: []Derived{{Column: "email", Source: DeriveEmail, TalentOnly: true}},
|
|
},
|
|
|
|
// An interview belongs to an application, and the application belongs to a
|
|
// person. Talent reads their own and may sit one for an application of
|
|
// theirs; the insert guard in the repository enforces the second half.
|
|
"ai-interviews": {
|
|
List: everyone, Create: everyone,
|
|
TalentScope: Scope{Kind: ScopeOwnApplications, Column: "application_id"},
|
|
},
|
|
|
|
// The employment record of the organization's workforce. Operators only:
|
|
// it carries endorsements, review dates and reviewer names, which are the
|
|
// organization's assessment of a person rather than the person's own data.
|
|
"staff": {
|
|
List: operators, Create: operators, Update: operators,
|
|
},
|
|
|
|
// A worker's own profile: contact details, address, salary expectations,
|
|
// personality assessment. Talent may read and maintain theirs and no other.
|
|
// user_id is server-owned, so a talent caller cannot claim someone else's
|
|
// profile by naming them, and cannot hand theirs away.
|
|
"worker-profiles": {
|
|
List: everyone, Create: everyone, Update: everyone,
|
|
TalentScope: Scope{Kind: ScopeUserID, Column: "user_id"},
|
|
Derived: []Derived{{Column: "user_id", Source: DeriveUserID, TalentOnly: true}},
|
|
},
|
|
|
|
// What a worker declares they do, as opposed to what the organization needs
|
|
// filled — that is job-postings. Operators maintain the organization's;
|
|
// talent reads their own and no one else's.
|
|
//
|
|
// Create is operators-only, and that is an I1 decision rather than a
|
|
// deferral of one. The worker is named explicitly on the row and is
|
|
// deliberately NOT derived from the session, because an operator recording
|
|
// a role on somebody's behalf is the whole point of the flow. Granting
|
|
// talent Create with the same shape would let a talent caller write a role
|
|
// under any worker_email in the tenant, which is precisely the attribution
|
|
// hole Phase 3D closed elsewhere. When a talent console exists, the grant
|
|
// arrives together with a TalentOnly derivation of worker_email — one line,
|
|
// not a migration, which is what the scope below is already in place for.
|
|
"employee-roles": {
|
|
List: everyone, Get: everyone,
|
|
Create: operators, Update: operators,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
|
|
Derived: []Derived{{Column: "created_by", Source: DeriveUserID}},
|
|
},
|
|
|
|
// Who is on which position. Operators allocate; talent reads their own
|
|
// roster and cannot create one — being assigned to work is not a thing you
|
|
// do to yourself.
|
|
"assignments": {
|
|
List: everyone, Create: operators,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
|
|
},
|
|
|
|
// Attendance. Read-only for everyone over the API — the seeder owns these
|
|
// rows — and talent sees only their own shifts.
|
|
"shift-records": {
|
|
List: everyone,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
|
|
},
|
|
|
|
// The training library. Everyone learns from it; only admin authors it.
|
|
// Employer is excluded from authoring deliberately: courses with a NULL
|
|
// org_id are the shared platform library, visible to every tenant, so a
|
|
// write here can reach beyond the writer's own organization.
|
|
"courses": {
|
|
List: everyone, Get: everyone,
|
|
Create: adminOnly, Update: adminOnly,
|
|
},
|
|
|
|
"learning-paths": {List: everyone},
|
|
|
|
// Organization taxonomy: the role names positions are filed under.
|
|
"role-categories": {
|
|
List: everyone, Create: operators,
|
|
},
|
|
|
|
// Organization taxonomy: the certifications positions may require.
|
|
// Deleting one changes what every existing posting means, so it is admin's.
|
|
"certifications": {
|
|
List: everyone, Create: operators, Delete: adminOnly,
|
|
},
|
|
|
|
// The audit log. Append-only by schema (no update, no delete). Anyone may
|
|
// write an entry about themselves — and only about themselves: all four
|
|
// identity columns are server-derived, so an entry cannot be attributed to
|
|
// someone else. Operators read the organization's log; talent reads theirs.
|
|
"user-activity": {
|
|
List: everyone, Create: everyone,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "user_email"},
|
|
Derived: []Derived{
|
|
{Column: "user_id", Source: DeriveUserID},
|
|
{Column: "user_email", Source: DeriveEmail},
|
|
{Column: "user_name", Source: DeriveFullName},
|
|
{Column: "account_type", Source: DeriveAccountType},
|
|
},
|
|
},
|
|
|
|
// Proof of work: a worker submits it, the organization verifies it.
|
|
// Talent may submit their own and read it back; the verdict is an operator
|
|
// judgement, so talent cannot PATCH.
|
|
"evidence": {
|
|
List: everyone, Create: everyone, Update: operators,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
|
|
Derived: []Derived{{Column: "worker_email", Source: DeriveEmail, TalentOnly: true}},
|
|
},
|
|
|
|
// Badges have no endpoints at all (Ops: 0 — the frontend's Badge.list call
|
|
// has 404ed since Phase 2C). The empty policy is written out rather than
|
|
// omitted so that the resource is deliberately closed rather than merely
|
|
// forgotten, and so TestEveryResourceHasAPolicy passes honestly.
|
|
"badges": {},
|
|
}
|
|
|
|
// init attaches the policies to the descriptors.
|
|
//
|
|
// A resource with no entry keeps a nil Policy and therefore permits nothing.
|
|
func init() {
|
|
for _, r := range AllResources {
|
|
r.Policy = policies[r.Path]
|
|
}
|
|
}
|