feat(roles): in-flight ratchet + auto_own_roles, drop DC subtree-admin
Two related schema/defaults changes that together replace the
admins:[document_controller] subtree-admin status with a cleaner
role-grant-via-auto-own model, and lock down the one-way handoff
through the in-flight lifecycle slots.
## New: auto_own_roles
ZddcFile.AutoOwnRoles []string is a new field on the parent's .zddc
declaring "when this directory's auto_own fires, also grant these
roles rwcda alongside the creator email". The writer
(WriteAutoOwnZddc + WriteAutoOwnZddcFenced) now takes a roles slice
and writes both the creator email AND each named role as rwcda in
the new .zddc. mergeOverlay treats AutoOwnRoles like other path-tree
contributions (leaf-wins).
The defaults' archive/<party>/ entry now sets
`auto_own_roles: [document_controller]` and drops the
`admins: [document_controller]` line:
- When any DC mkdir's archive/<party>/, the auto-own .zddc grants
both their email and the role rwcda. Peer DCs share full
authority at every party without any DC needing subtree-admin
status.
- DCs are no longer subtree-admins anywhere. They can't bypass
WORM (only worm-create via the worm: list) and can't reach
inside fenced working homes. Admin elevation is reserved for
the root admins: list.
- Plan Review's ActionAdmin pre-flight passes for any DC via the
role grant cascading into reviewing/ and staging/.
## In-flight ratchet (working → staging → issued)
Per-role grants at the lifecycle slots formalise a one-way handoff:
working/ project_team: cr (create their own folders;
auto_own_fenced gives rwcda inside)
staging/ project_team: cr (drop files, no modify after — the
"commit" step; DC takes over)
document_controller: rwcd (transfer-to-issued needs `d`)
reviewing/ project_team: cr (create iteration folders; auto_own
unfenced grants rwcda inside)
received/ worm cr (file write-once)
issued/ worm cr
Each handoff drops the previous role's modify rights for the slot
they pushed from. Comments in defaults.zddc.yaml document the
pattern + the "project_team drops files at staging root, never
mkdirs" convention.
## Tests
TestStandardRoles_DocControllerScopedCreate rewritten — flips
from IsSubtreeAdmin assertions to verifying:
- rwcda at <party>/ via the auto-own .zddc (creator + role)
- rwcda cascading to working/reviewing/ (no slot override)
- rwcd at incoming/staging/ via explicit grants
- cr at received/issued via WORM mask
- IsSubtreeAdmin = false everywhere
- DC blocked from alice's fenced working/<email>/ home
New TestStandardRoles_DocControllerMultiDC — a second DC in the
role gets the same rwcda at any party a peer created, via the role
grant in auto_own_roles.
New TestStandardRoles_ProjectTeamInFlightRatchet locks the ratchet:
project_team gets cr at working/staging/reviewing, r at incoming/
received/issued.
New TestStandardRoles_DocControllerStagingDelete confirms DC has
`d` at staging/ for the transfer-to-issued workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b5a725e745
commit
ba98b87b2a
9 changed files with 404 additions and 85 deletions
|
|
@ -758,11 +758,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
// unfenced so ancestor grants still cascade through.
|
// unfenced so ancestor grants still cascade through.
|
||||||
if email != "" {
|
if email != "" {
|
||||||
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
||||||
|
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
|
||||||
var werr error
|
var werr error
|
||||||
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
|
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
|
||||||
werr = zddc.WriteAutoOwnZddcFenced(abs, email)
|
werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles)
|
||||||
} else {
|
} else {
|
||||||
werr = zddc.WriteAutoOwnZddc(abs, email)
|
werr = zddc.WriteAutoOwnZddc(abs, email, roles)
|
||||||
}
|
}
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)
|
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,12 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
|
||||||
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
|
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
|
||||||
// fires here exactly as it would for a manual mkdir.
|
// fires here exactly as it would for a manual mkdir.
|
||||||
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
|
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
|
||||||
|
roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs)
|
||||||
var werr error
|
var werr error
|
||||||
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
|
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
|
||||||
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
|
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles)
|
||||||
} else {
|
} else {
|
||||||
werr = zddc.WriteAutoOwnZddc(partyAbs, email)
|
werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles)
|
||||||
}
|
}
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
|
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
|
||||||
|
|
|
||||||
|
|
@ -34,22 +34,53 @@ acl:
|
||||||
# the reset are then excluded.
|
# the reset are then excluded.
|
||||||
#
|
#
|
||||||
# document_controller — the people who file into
|
# document_controller — the people who file into
|
||||||
# archive/<party>/received/ and issued/ (WORM zones). They get
|
# archive/<party>/received/ and issued/ (WORM zones). They get:
|
||||||
# read+write-once-create there (via the worm: lists below) and
|
# - rwcda at every archive/<party>/ via the role grant written
|
||||||
# read/write elsewhere in a project, plus subtree-admin of the
|
# into each party's auto-own .zddc (auto_own_roles below).
|
||||||
# per-party working/ + staging/ + reviewing/ so they can stand up
|
# Cascade carries rwcda down to descendants by default.
|
||||||
# and manage drafting/transmittal/review folders. They are NOT
|
# - read+write-once-create at received/issued via the worm:
|
||||||
# subtree-admin of archive/<party>/, so the WORM constraint still
|
# lists (the WORM mask strips w/d/a even though the role
|
||||||
# binds them in received/issued. Plan-Review approval is part of
|
# grant supplies rwcda at the party level above).
|
||||||
# this role by design — there is no separate `approver` role;
|
# - rwcd explicit at incoming/ and staging/ (the QC and
|
||||||
# two-person sign-off, when needed, is expressed via per-folder
|
# transmittal-out workflows need `d` to move files between
|
||||||
# `.zddc` overrides rather than baked-in roles.
|
# slots; the explicit grants shadow the inherited rwcda
|
||||||
|
# to make the intent visible).
|
||||||
|
# - rwc at archive/ so they can create party subfolders.
|
||||||
|
#
|
||||||
|
# NOT a subtree-admin anywhere. There is no `admins:` entry for
|
||||||
|
# the role — DCs cannot bypass WORM (only worm-create via the
|
||||||
|
# list) and cannot reach inside fenced working homes. Admin
|
||||||
|
# elevation is reserved for the root admins: list (the human
|
||||||
|
# escape hatch for mis-filed documents or recovery).
|
||||||
|
#
|
||||||
|
# Plan-Review approval is part of this role by design — there is
|
||||||
|
# no separate `approver` role; two-person sign-off, when needed,
|
||||||
|
# is expressed via per-folder `.zddc` overrides rather than
|
||||||
|
# baked-in roles.
|
||||||
#
|
#
|
||||||
# project_team — everyone working on a project. Read-only across
|
# project_team — everyone working on a project. Read-only across
|
||||||
# the project. Their own archive/<party>/working/<email>/ home and
|
# the project by default, with a one-way ratchet through the
|
||||||
# anything they create under incoming/ get a creator-owned auto-
|
# in-flight slots:
|
||||||
# own .zddc (rwcda) which wins via deepest-match, so "read-only
|
#
|
||||||
# except what I own" falls out of the cascade with no special rule.
|
# working/ cr — create + read; the auto_own_fenced child
|
||||||
|
# gives the creator rwcda in their own home,
|
||||||
|
# fenced from siblings
|
||||||
|
# staging/ cr — drop + read, no modify (after drop, the
|
||||||
|
# doc-controller is the only one who can
|
||||||
|
# change it)
|
||||||
|
# reviewing/ cr — create + read; auto_own (unfenced) gives
|
||||||
|
# creator rwcda in their iteration folder,
|
||||||
|
# siblings see it via project-level :r
|
||||||
|
# received/ r — WORM zone; only document_controller can
|
||||||
|
# file (and even they need elevation to edit)
|
||||||
|
# issued/ r — WORM zone; published, immutable
|
||||||
|
# incoming/ r — counterparty's drop zone (project_team
|
||||||
|
# observers it, doc_controller QCs it)
|
||||||
|
#
|
||||||
|
# "Each handoff drops the role's modify rights for the previous
|
||||||
|
# slot." That's the model — project_team works freely in
|
||||||
|
# working/, commits to staging/, and from there the doc-
|
||||||
|
# controller takes over.
|
||||||
#
|
#
|
||||||
# observer — pure read-only across the project. Like project_team
|
# observer — pure read-only across the project. Like project_team
|
||||||
# but with no auto-own home: an observer who somehow created a
|
# but with no auto-own home: an observer who somehow created a
|
||||||
|
|
@ -197,19 +228,31 @@ paths:
|
||||||
paths:
|
paths:
|
||||||
# Second segment under archive/ is the party name.
|
# Second segment under archive/ is the party name.
|
||||||
"*":
|
"*":
|
||||||
# When the doc controller creates a party folder, an
|
# When the doc controller creates a party folder, the
|
||||||
# auto-own .zddc grants them rwcda there (UNFENCED — so
|
# auto-own .zddc grants:
|
||||||
# the project-level project_team:r still cascades through
|
# - the creator's email rwcda (the standard auto_own
|
||||||
# to received/issued). That lets them set up the
|
# mechanism)
|
||||||
# counterparty's own .zddc afterward.
|
# - the document_controller role rwcda (auto_own_roles
|
||||||
|
# below) so any DC in the role has full authority at
|
||||||
|
# every party, not just the parties they personally
|
||||||
|
# mkdir'd
|
||||||
|
#
|
||||||
|
# UNFENCED — so the project-level project_team:r still
|
||||||
|
# cascades through to received/issued/incoming. That
|
||||||
|
# lets the DC who created the party set up the counter-
|
||||||
|
# party's own .zddc afterward (e.g. granting them cr at
|
||||||
|
# incoming/).
|
||||||
|
#
|
||||||
|
# No `admins:` here by design. The DC role gets full
|
||||||
|
# authority via the role grant in the auto-own .zddc, not
|
||||||
|
# via subtree-admin status — so WORM masks at
|
||||||
|
# received/issued still bind them (they file write-once
|
||||||
|
# via the worm: list), and per-user fenced homes under
|
||||||
|
# working/ stay private to their creators. Admin
|
||||||
|
# elevation is reserved for the root admins list (the
|
||||||
|
# actual sudo-style escape hatch).
|
||||||
auto_own: true
|
auto_own: true
|
||||||
# Doc controller is subtree-admin of this party folder —
|
auto_own_roles: [document_controller]
|
||||||
# full manage authority over the in-flight lifecycle
|
|
||||||
# slots (working/staging/reviewing) declared below. The
|
|
||||||
# WORM constraint on received/issued is enforced by the
|
|
||||||
# cascade's worm: lists, not by admin grants, so they
|
|
||||||
# still file write-once into those slots.
|
|
||||||
admins: [document_controller]
|
|
||||||
# SSR record: the party folder's ssr.yaml carries this
|
# SSR record: the party folder's ssr.yaml carries this
|
||||||
# party's vendor / contract / status data. Scoped by
|
# party's vendor / contract / status data. Scoped by
|
||||||
# filename pattern so the lock on `kind` only applies to
|
# filename pattern so the lock on `kind` only applies to
|
||||||
|
|
@ -324,9 +367,39 @@ paths:
|
||||||
# level <project>/{working,staging,reviewing} virtuals
|
# level <project>/{working,staging,reviewing} virtuals
|
||||||
# (declared above) are folder-nav views over these
|
# (declared above) are folder-nav views over these
|
||||||
# canonical per-party slots.
|
# canonical per-party slots.
|
||||||
|
# ── In-flight ratchet ───────────────────────────────
|
||||||
|
#
|
||||||
|
# The lifecycle slots form a one-way handoff:
|
||||||
|
#
|
||||||
|
# working/ → staging/ → issued/ (WORM)
|
||||||
|
# (full) (cr) (worm cr)
|
||||||
|
#
|
||||||
|
# At each step the previous role's modify rights drop:
|
||||||
|
# project_team iterates freely in working/; when they
|
||||||
|
# promote to staging/ they can't change it without doc-
|
||||||
|
# controller help; when DC publishes to issued/ even
|
||||||
|
# they can't change it without elevation. Each ACL
|
||||||
|
# grant below is the verb-set the ROLE keeps at that
|
||||||
|
# step; auto_own + auto_own_fenced sub-folder grants
|
||||||
|
# layer per-creator ownership on top of these.
|
||||||
working:
|
working:
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
available_tools: [browse, classifier]
|
available_tools: [browse, classifier]
|
||||||
|
# Project_team gets read + create here so they can
|
||||||
|
# mkdir their own home folder (and any shared sub-
|
||||||
|
# folders). The auto_own_fenced declaration at the
|
||||||
|
# `*` child below makes the new folder a private home
|
||||||
|
# with rwcda for the creator (fenced from ancestors,
|
||||||
|
# so collaborators only join after the owner edits
|
||||||
|
# the home's .zddc to grant them access).
|
||||||
|
#
|
||||||
|
# `cr` instead of just `c` so an existing file at
|
||||||
|
# working/ root stays readable to all team members
|
||||||
|
# (cascade is per-level deepest-match — a single `c`
|
||||||
|
# would shadow the project-level `r`).
|
||||||
|
acl:
|
||||||
|
permissions:
|
||||||
|
project_team: cr
|
||||||
# working/ auto-owns the first creator + the per-user
|
# working/ auto-owns the first creator + the per-user
|
||||||
# homes below.
|
# homes below.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
|
|
@ -345,6 +418,29 @@ paths:
|
||||||
staging:
|
staging:
|
||||||
default_tool: transmittal
|
default_tool: transmittal
|
||||||
available_tools: [transmittal, classifier]
|
available_tools: [transmittal, classifier]
|
||||||
|
# The ratchet step from working/. project_team gets
|
||||||
|
# `cr` — they can drop files (PUT new files at
|
||||||
|
# staging/) and read what's there, but cannot edit or
|
||||||
|
# delete after the drop. Once a file is in staging it
|
||||||
|
# belongs to the doc-controller workflow; the team
|
||||||
|
# member needs to ask DC to change it.
|
||||||
|
#
|
||||||
|
# Convention: project_team drops FILES at staging/,
|
||||||
|
# not sub-folders. A sub-folder mkdir'd by project_
|
||||||
|
# team would trigger auto_own and grant them rwcda
|
||||||
|
# inside their own sub-folder (auto_own is path-keyed,
|
||||||
|
# not role-keyed — it fires for any creator). The
|
||||||
|
# auto_own here is preserved for DC's per-transmittal
|
||||||
|
# mkdir flow; project_team can keep to file drops to
|
||||||
|
# honour the "can't alter after" intent.
|
||||||
|
#
|
||||||
|
# DC gets rwcd explicitly — the staging-to-issued
|
||||||
|
# transfer needs `d` (cut, not copy) to move files
|
||||||
|
# out. Mirrors the incoming/ pattern at line 286-288.
|
||||||
|
acl:
|
||||||
|
permissions:
|
||||||
|
project_team: cr
|
||||||
|
document_controller: rwcd
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
reviewing:
|
reviewing:
|
||||||
|
|
@ -359,5 +455,15 @@ paths:
|
||||||
# from the party-level admins:) so the doc
|
# from the party-level admins:) so the doc
|
||||||
# controller can author per-folder .zddc files
|
# controller can author per-folder .zddc files
|
||||||
# (originator ACL, planned_date).
|
# (originator ACL, planned_date).
|
||||||
|
#
|
||||||
|
# project_team gets `cr` so the originating team can
|
||||||
|
# create review-iteration folders alongside the
|
||||||
|
# Plan-Review-scaffolded ones. auto_own (unfenced
|
||||||
|
# here, unlike working/) gives the creator rwcda
|
||||||
|
# inside; siblings see the iteration via the project-
|
||||||
|
# level project_team:r cascade.
|
||||||
|
acl:
|
||||||
|
permissions:
|
||||||
|
project_team: cr
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
absPath string
|
absPath string
|
||||||
autoOwn bool
|
autoOwn bool
|
||||||
fenced bool
|
fenced bool
|
||||||
|
roles []string
|
||||||
}
|
}
|
||||||
var freshlyCreated []created
|
var freshlyCreated []created
|
||||||
|
|
||||||
|
|
@ -224,10 +225,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
_ = i
|
_ = i
|
||||||
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
||||||
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
|
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
|
||||||
|
var roles []string
|
||||||
|
if autoOwn {
|
||||||
|
roles = AutoOwnRolesAt(fsRoot, pathSoFar)
|
||||||
|
}
|
||||||
freshlyCreated = append(freshlyCreated, created{
|
freshlyCreated = append(freshlyCreated, created{
|
||||||
absPath: pathSoFar,
|
absPath: pathSoFar,
|
||||||
autoOwn: autoOwn,
|
autoOwn: autoOwn,
|
||||||
fenced: fenced,
|
fenced: fenced,
|
||||||
|
roles: roles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +241,10 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
// created. Skip if no principal email is available (anonymous or
|
// created. Skip if no principal email is available (anonymous or
|
||||||
// system writes). The fenced variant is used at per-user home
|
// system writes). The fenced variant is used at per-user home
|
||||||
// folders under working/ — private by default; owner can later
|
// folders under working/ — private by default; owner can later
|
||||||
// edit the .zddc to add collaborators.
|
// edit the .zddc to add collaborators. Role grants (from the
|
||||||
|
// cascade's auto_own_roles list) are written alongside the
|
||||||
|
// creator email so role-level peer authority survives without
|
||||||
|
// needing a subtree-admin grant.
|
||||||
if principalEmail != "" {
|
if principalEmail != "" {
|
||||||
for _, c := range freshlyCreated {
|
for _, c := range freshlyCreated {
|
||||||
if !c.autoOwn {
|
if !c.autoOwn {
|
||||||
|
|
@ -243,9 +252,9 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
}
|
}
|
||||||
var werr error
|
var werr error
|
||||||
if c.fenced {
|
if c.fenced {
|
||||||
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail)
|
werr = WriteAutoOwnZddcFenced(c.absPath, principalEmail, c.roles)
|
||||||
} else {
|
} else {
|
||||||
werr = WriteAutoOwnZddc(c.absPath, principalEmail)
|
werr = WriteAutoOwnZddc(c.absPath, principalEmail, c.roles)
|
||||||
}
|
}
|
||||||
if werr != nil {
|
if werr != nil {
|
||||||
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)
|
return target, fmt.Errorf("auto-own .zddc at %q: %w", c.absPath, werr)
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,23 @@ type ZddcFile struct {
|
||||||
// admin grants still apply.
|
// admin grants still apply.
|
||||||
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
||||||
|
|
||||||
|
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
|
||||||
|
// the auto-own .zddc written at a new child directory grants each
|
||||||
|
// listed role `rwcda` ALONGSIDE the creator email. Lets the schema
|
||||||
|
// express "the creator owns it AND any member of these roles has
|
||||||
|
// full authority" without resorting to a separate admins: list
|
||||||
|
// (which would be subtree-admin and bypass WORM / fences via
|
||||||
|
// elevation — too strong for typical workflows).
|
||||||
|
//
|
||||||
|
// Example: archive/<party>/ sets `auto_own_roles: [document_controller]`
|
||||||
|
// so any DC has rwcda at every party folder a peer created, not
|
||||||
|
// just at parties they personally mkdir'd.
|
||||||
|
//
|
||||||
|
// Grants are written as plain permissions in the new .zddc — they
|
||||||
|
// have no special semantic beyond what `rwcda` already means in
|
||||||
|
// the cascade. A fence (auto_own_fenced) still binds them.
|
||||||
|
AutoOwnRoles []string `yaml:"auto_own_roles,omitempty" json:"auto_own_roles,omitempty"`
|
||||||
|
|
||||||
// Virtual marks a directory as never-materialise-on-disk. The
|
// Virtual marks a directory as never-materialise-on-disk. The
|
||||||
// server treats requests under such a path as virtual routes
|
// server treats requests under such a path as virtual routes
|
||||||
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,23 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoOwnRolesAt returns the role names that should be granted rwcda
|
||||||
|
// in the auto-own .zddc at this dir (alongside the creator's email).
|
||||||
|
// Leaf-only, same semantic as AutoOwnAt / AutoOwnFencedAt. Empty/nil
|
||||||
|
// when the cascade declares no role grants — the legacy creator-only
|
||||||
|
// behavior. Caller passes the result to WriteAutoOwnZddc / Fenced.
|
||||||
|
func AutoOwnRolesAt(fsRoot, dirPath string) []string {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
leaf := leafLevel(chain)
|
||||||
|
if leaf.AutoOwnRoles != nil {
|
||||||
|
return leaf.AutoOwnRoles
|
||||||
|
}
|
||||||
|
return chain.Embedded.AutoOwnRoles
|
||||||
|
}
|
||||||
|
|
||||||
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir
|
// AutoOwnFencedAt reports whether the auto-own .zddc at this dir
|
||||||
// should be written with `inherit: false` (private to creator).
|
// should be written with `inherit: false` (private to creator).
|
||||||
// Leaf-only, same semantic as AutoOwnAt.
|
// Leaf-only, same semantic as AutoOwnAt.
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,24 @@ import (
|
||||||
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
|
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
|
||||||
|
|
||||||
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
|
||||||
// principalEmail rwcda and recording it in CreatedBy. Used by the file
|
// principalEmail rwcda and recording it in CreatedBy. Each role name
|
||||||
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
|
// in roles also receives rwcda — gives the schema a way to declare
|
||||||
// ownership when a new auto-own folder is materialised.
|
// "this folder is creator-owned AND any member of these roles has full
|
||||||
|
// authority" without using subtree-admin (which would bypass WORM /
|
||||||
|
// fences via elevation). Used by the file API's mkdir post-hook (and
|
||||||
|
// by EnsureCanonicalAncestors) to seed ownership when a new auto-own
|
||||||
|
// folder is materialised. Pass nil/empty roles for the legacy
|
||||||
|
// creator-only behavior.
|
||||||
//
|
//
|
||||||
// The grant is identical to what an operator would write by hand —
|
// The grants are identical to what an operator would write by hand —
|
||||||
// direct email pattern, "rwcda" verb set — so the creator can later
|
// direct email pattern + bare role names, "rwcda" verb set — so the
|
||||||
// edit the file normally to add collaborators.
|
// creator can later edit the file normally to narrow or extend them.
|
||||||
//
|
//
|
||||||
// Atomic: marshals via the same yaml encoder ParseFile reads
|
// Atomic: marshals via the same yaml encoder ParseFile reads
|
||||||
// (round-trip guaranteed) and writes via zddc.WriteFile (which
|
// (round-trip guaranteed) and writes via zddc.WriteFile (which
|
||||||
// performs an atomic temp-write + rename via zddc.WriteAtomic).
|
// performs an atomic temp-write + rename via zddc.WriteAtomic).
|
||||||
func WriteAutoOwnZddc(dir, principalEmail string) error {
|
func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
|
||||||
return writeAutoOwn(dir, principalEmail, false)
|
return writeAutoOwn(dir, principalEmail, false, roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
||||||
|
|
@ -34,14 +39,26 @@ func WriteAutoOwnZddc(dir, principalEmail string) error {
|
||||||
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
|
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
|
||||||
// authenticated users) would let any user read every other user's
|
// authenticated users) would let any user read every other user's
|
||||||
// working subfolder via cascade — defeating the per-user sandbox.
|
// working subfolder via cascade — defeating the per-user sandbox.
|
||||||
func WriteAutoOwnZddcFenced(dir, principalEmail string) error {
|
//
|
||||||
return writeAutoOwn(dir, principalEmail, true)
|
// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
|
||||||
|
// alongside the creator, and like the creator grant they're INSIDE
|
||||||
|
// the fence (only resolvable if the role is defined at this level or
|
||||||
|
// in chain.Embedded, since ancestor role definitions are hidden by
|
||||||
|
// inherit:false). Typically callers using the fenced variant pass nil
|
||||||
|
// roles — per-user homes don't need peer authority.
|
||||||
|
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
|
||||||
|
return writeAutoOwn(dir, principalEmail, true, roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAutoOwn(dir, principalEmail string, fenced bool) error {
|
func writeAutoOwn(dir, principalEmail string, fenced bool, roles []string) error {
|
||||||
rules := ACLRules{
|
perms := map[string]string{principalEmail: "rwcda"}
|
||||||
Permissions: map[string]string{principalEmail: "rwcda"},
|
for _, role := range roles {
|
||||||
|
if role == "" || role == principalEmail {
|
||||||
|
continue // skip empty / collision with the creator entry
|
||||||
}
|
}
|
||||||
|
perms[role] = "rwcda"
|
||||||
|
}
|
||||||
|
rules := ACLRules{Permissions: perms}
|
||||||
if fenced {
|
if fenced {
|
||||||
f := false
|
f := false
|
||||||
rules.Inherit = &f
|
rules.Inherit = &f
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,51 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestStandardRoles_DocControllerScopedCreate — with document_controller
|
// TestStandardRoles_DocControllerScopedCreate — DC authority comes
|
||||||
// populated at the on-disk root, the role gets:
|
// PURELY from the cascade now (no subtree-admin / admins: list). The
|
||||||
// - rw at the project level (read + overwrite-existing), but NOT c
|
// model:
|
||||||
// (so it can't make arbitrary folders)
|
// - rw at the project level (read + overwrite-existing, no `c`)
|
||||||
// - rwc at archive/ (can create party subfolders)
|
// - rwc at archive/ (can create party subfolders)
|
||||||
// - subtree-admin at archive/<party>/ (full create + manage; lifecycle
|
// - When DC mkdirs archive/<party>/, ensure.go writes an auto-own
|
||||||
// slots under the party inherit the admin grant)
|
// .zddc granting both the creator email AND the document_controller
|
||||||
// - inside received/issued (WORM): masked to r + worm-restored c
|
// role rwcda there (via auto_own_roles in defaults). This test
|
||||||
//
|
// simulates that .zddc directly so the cascade behaviour can be
|
||||||
// Layout reshape: working/staging/reviewing moved from project root
|
// asserted in isolation.
|
||||||
// into archive/<party>/, so the subtree-admin scope likewise moved
|
// - From the party's auto-own .zddc, the role rwcda cascades down to
|
||||||
// from project-level "working/staging/" to the per-party folder.
|
// descendants by default; explicit slot grants (rwcd at incoming/
|
||||||
|
// and staging/) shadow it where the workflow needs `d`.
|
||||||
|
// - At received/issued (WORM): the WORM mask strips w/d/a from the
|
||||||
|
// inherited rwcda; the worm: list restores c → effective cr.
|
||||||
|
// - NOT subtree-admin anywhere — no admins: entries for the role.
|
||||||
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
// Deployment populates the standard roles. Roles UNION with the
|
// Note: project_team's wildcard would normally also match dc@,
|
||||||
// embedded (empty) definitions, so this is the effective member set.
|
// which would shadow the role's rwcda at working/ (where the slot
|
||||||
|
// explicitly grants project_team:cr). Real deployments keep
|
||||||
|
// document_controller and project_team disjoint; the test fixture
|
||||||
|
// mirrors that.
|
||||||
writeZddc(t, root, `roles:
|
writeZddc(t, root, `roles:
|
||||||
document_controller:
|
document_controller:
|
||||||
members: ["dc@example.com"]
|
members: ["dc@example.com"]
|
||||||
project_team:
|
project_team:
|
||||||
members: ["*@example.com"]
|
members: ["alice@example.com"]
|
||||||
`)
|
`)
|
||||||
|
// Simulate the auto-own .zddc the file API writes when DC mkdir's
|
||||||
|
// archive/Acme/. Carries the creator email + the document_controller
|
||||||
|
// role per the embedded defaults' auto_own_roles entry.
|
||||||
|
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
|
||||||
|
if err := os.MkdirAll(partyDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeZddc(t, partyDir, `acl:
|
||||||
|
permissions:
|
||||||
|
"dc@example.com": rwcda
|
||||||
|
document_controller: rwcda
|
||||||
|
created_by: dc@example.com
|
||||||
|
`)
|
||||||
|
resetCache()
|
||||||
|
|
||||||
dc := "dc@example.com"
|
dc := "dc@example.com"
|
||||||
|
|
||||||
mustVerbs := func(dir string, want string) {
|
mustVerbs := func(dir string, want string) {
|
||||||
|
|
@ -37,7 +59,6 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
||||||
}
|
}
|
||||||
// Mirror InternalDecider.Allow's WORM-aware composition.
|
|
||||||
var got VerbSet
|
var got VerbSet
|
||||||
if g, inWorm := WormZoneGrant(chain, dc); inWorm {
|
if g, inWorm := WormZoneGrant(chain, dc); inWorm {
|
||||||
got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
|
got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
|
||||||
|
|
@ -55,36 +76,92 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
||||||
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
|
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
|
||||||
// archive/: rwc (can create party folders).
|
// archive/: rwc (can create party folders).
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
|
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc")
|
||||||
// incoming/: rwcd — the QC + transfer-out workflow needs delete.
|
// At the party folder itself: rwcda via the auto-own role grant.
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "rwcd")
|
mustVerbs(partyDir, "rwcda")
|
||||||
// received/ (WORM): rw masked to r, plus worm-restored c → "rc".
|
// Lifecycle slots inside the party folder inherit rwcda from the
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc")
|
// party-level role grant where no slot-local grant overrides.
|
||||||
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc")
|
mustVerbs(filepath.Join(partyDir, "working"), "rwcda")
|
||||||
|
mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda")
|
||||||
|
// incoming/ and staging/ have explicit document_controller: rwcd
|
||||||
|
// — leaf-wins shadows the rwcda inherited from <party>/.
|
||||||
|
mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd")
|
||||||
|
mustVerbs(filepath.Join(partyDir, "staging"), "rwcd")
|
||||||
|
// received/ (WORM): inherited rwcda masked to r + worm-restored c.
|
||||||
|
mustVerbs(filepath.Join(partyDir, "received"), "rc")
|
||||||
|
mustVerbs(filepath.Join(partyDir, "issued"), "rc")
|
||||||
|
|
||||||
// Subtree-admin at archive/<party>/ (the embedded cascade
|
// NOT subtree-admin anywhere — even when notionally elevated,
|
||||||
// declares admins: [document_controller] on the party "*" entry,
|
// the role carries no admin: grant.
|
||||||
// so working/staging/reviewing inside the party inherit it).
|
for _, p := range []string{
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) {
|
filepath.Join(root, "Proj"),
|
||||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/")
|
filepath.Join(root, "Proj", "archive"),
|
||||||
|
partyDir,
|
||||||
|
filepath.Join(partyDir, "working"),
|
||||||
|
filepath.Join(partyDir, "staging"),
|
||||||
|
filepath.Join(partyDir, "reviewing"),
|
||||||
|
filepath.Join(partyDir, "received"),
|
||||||
|
filepath.Join(partyDir, "issued"),
|
||||||
|
} {
|
||||||
|
if IsSubtreeAdmin(root, p, Principal{Email: dc, Elevated: true}) {
|
||||||
|
t.Errorf("doc controller should NOT be subtree-admin of %s (no admins: list anywhere)", p[len(root):])
|
||||||
}
|
}
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) {
|
|
||||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/working/")
|
|
||||||
}
|
}
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) {
|
// And specifically — they CAN'T reach inside a fenced per-user
|
||||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/staging/")
|
// working home. The fence isolates team-member workspaces from
|
||||||
|
// every other role (including DC) by design.
|
||||||
|
homeDir := filepath.Join(partyDir, "working", "alice@example.com")
|
||||||
|
if err := os.MkdirAll(homeDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) {
|
writeZddc(t, homeDir, `acl:
|
||||||
t.Errorf("doc controller should be subtree-admin of archive/<party>/reviewing/")
|
inherit: false
|
||||||
|
permissions:
|
||||||
|
"alice@example.com": rwcda
|
||||||
|
created_by: alice@example.com
|
||||||
|
`)
|
||||||
|
resetCache()
|
||||||
|
chain, _ := EffectivePolicy(root, homeDir)
|
||||||
|
if got := EffectiveVerbs(chain, dc); got != 0 {
|
||||||
|
t.Errorf("doc controller inside alice's fenced home = %q, want empty (fence isolates)", got.String())
|
||||||
}
|
}
|
||||||
// NOT subtree-admin of archive/ (so WORM still binds them at the
|
|
||||||
// received/issued slots below).
|
|
||||||
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) {
|
|
||||||
t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)")
|
|
||||||
}
|
}
|
||||||
// Subtree-admin reaches inside a fenced per-user working home
|
|
||||||
// under the party's working slot.
|
// TestStandardRoles_DocControllerMultiDC — a second DC added to the
|
||||||
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
|
// role gets the SAME rwcda at every party that any DC created,
|
||||||
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home")
|
// because the auto-own .zddc grants the role (not just the creator's
|
||||||
|
// email) via auto_own_roles in defaults.
|
||||||
|
func TestStandardRoles_DocControllerMultiDC(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeZddc(t, root, `roles:
|
||||||
|
document_controller:
|
||||||
|
members: ["dc1@example.com", "dc2@example.com"]
|
||||||
|
`)
|
||||||
|
// dc1 created the party folder; the auto-own .zddc lists both
|
||||||
|
// dc1 (creator email) and the document_controller role (from
|
||||||
|
// auto_own_roles in defaults).
|
||||||
|
partyDir := filepath.Join(root, "Proj", "archive", "Acme")
|
||||||
|
if err := os.MkdirAll(partyDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeZddc(t, partyDir, `acl:
|
||||||
|
permissions:
|
||||||
|
"dc1@example.com": rwcda
|
||||||
|
document_controller: rwcda
|
||||||
|
created_by: dc1@example.com
|
||||||
|
`)
|
||||||
|
resetCache()
|
||||||
|
|
||||||
|
chain, _ := EffectivePolicy(root, partyDir)
|
||||||
|
// dc1 (creator) has rwcda directly.
|
||||||
|
if got := EffectiveVerbs(chain, "dc1@example.com"); got.String() != "rwcda" {
|
||||||
|
t.Errorf("dc1 (creator) at party = %q, want rwcda", got.String())
|
||||||
|
}
|
||||||
|
// dc2 (non-creator) ALSO has rwcda via the role grant. This is
|
||||||
|
// the whole point of auto_own_roles — peer DCs share authority
|
||||||
|
// without admin status.
|
||||||
|
if got := EffectiveVerbs(chain, "dc2@example.com"); got.String() != "rwcda" {
|
||||||
|
t.Errorf("dc2 (peer) at party = %q, want rwcda (role grant from auto_own_roles)", got.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,6 +218,73 @@ created_by: alice@example.com
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStandardRoles_ProjectTeamInFlightRatchet — the one-way handoff
|
||||||
|
// from working/ → staging/ → issued/ as the team member sees it:
|
||||||
|
// full work in working (cr at the slot + rwcda inside the fenced
|
||||||
|
// home), drop-only in staging (cr — no modify after the drop), drop
|
||||||
|
// inside auto-own iteration folder in reviewing (cr at the slot,
|
||||||
|
// rwcda inside the auto-owned sub-folder), read-only in received/
|
||||||
|
// issued (WORM zones) and incoming/ (counterparty drop zone).
|
||||||
|
func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeZddc(t, root, `roles:
|
||||||
|
project_team:
|
||||||
|
members: ["*@example.com"]
|
||||||
|
`)
|
||||||
|
alice := "alice@example.com"
|
||||||
|
|
||||||
|
mustVerbs := func(dir string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
chain, err := EffectivePolicy(root, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
|
||||||
|
}
|
||||||
|
var got VerbSet
|
||||||
|
if g, inWorm := WormZoneGrant(chain, alice); inWorm {
|
||||||
|
got = (EffectiveVerbs(chain, alice) & VerbR) | (g & VerbsRC)
|
||||||
|
} else {
|
||||||
|
got = EffectiveVerbs(chain, alice)
|
||||||
|
}
|
||||||
|
if got.String() != want {
|
||||||
|
t.Errorf("project_team alice at %s = %q, want %q", dir[len(root):], got.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
party := filepath.Join(root, "Proj", "archive", "Acme")
|
||||||
|
mustVerbs(filepath.Join(party, "working"), "rc") // create + read at slot
|
||||||
|
mustVerbs(filepath.Join(party, "staging"), "rc") // drop + read, no modify
|
||||||
|
mustVerbs(filepath.Join(party, "reviewing"), "rc") // create iteration folders
|
||||||
|
mustVerbs(filepath.Join(party, "received"), "r") // WORM — read pass-through, no worm-create
|
||||||
|
mustVerbs(filepath.Join(party, "issued"), "r") // WORM — same
|
||||||
|
mustVerbs(filepath.Join(party, "incoming"), "r") // counterparty drop zone — read only
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStandardRoles_DocControllerStagingDelete — DC needs `d` at
|
||||||
|
// staging/ to perform the staging-to-issued transfer (cut, not copy);
|
||||||
|
// the explicit document_controller: rwcd grant supplies it. Mirrors
|
||||||
|
// the incoming/ pattern (line 286-288 of defaults.zddc.yaml) where
|
||||||
|
// the QC + transfer-out workflow needed the same.
|
||||||
|
func TestStandardRoles_DocControllerStagingDelete(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeZddc(t, root, `roles:
|
||||||
|
document_controller:
|
||||||
|
members: ["dc@example.com"]
|
||||||
|
`)
|
||||||
|
dc := "dc@example.com"
|
||||||
|
chain, err := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EffectivePolicy: %v", err)
|
||||||
|
}
|
||||||
|
got := EffectiveVerbs(chain, dc)
|
||||||
|
for _, v := range []VerbSet{VerbR, VerbW, VerbC, VerbD} {
|
||||||
|
if !got.Has(v) {
|
||||||
|
t.Errorf("doc controller at staging/ missing verb %q in %q (need rwcd for transfer-to-issued)", v.String(), got.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
|
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
|
||||||
// project-wide read-only role for auditors / regulators / external
|
// project-wide read-only role for auditors / regulators / external
|
||||||
// viewers. Unlike project_team, an observer must not contribute
|
// viewers. Unlike project_team, an observer must not contribute
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.AutoOwnFenced != nil {
|
if top.AutoOwnFenced != nil {
|
||||||
out.AutoOwnFenced = top.AutoOwnFenced
|
out.AutoOwnFenced = top.AutoOwnFenced
|
||||||
}
|
}
|
||||||
|
// AutoOwnRoles: presence (non-nil) overrides; a deeper level
|
||||||
|
// declaring an empty list replaces (and explicitly suppresses)
|
||||||
|
// the ancestor's role list. This matches the leaf-wins semantic
|
||||||
|
// for the other path-tree contribution lists.
|
||||||
|
if top.AutoOwnRoles != nil {
|
||||||
|
out.AutoOwnRoles = top.AutoOwnRoles
|
||||||
|
}
|
||||||
if top.DropTarget != nil {
|
if top.DropTarget != nil {
|
||||||
out.DropTarget = top.DropTarget
|
out.DropTarget = top.DropTarget
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue