IAM U1 + U3A capability-association + capability-explanation #219

Merged
weichen merged 3 commits from feature/iam-link-u1-capability-association into main 2026-10-19 13:47:25 +00:00
19 changed files with 2123 additions and 13 deletions
+64
View File
@@ -371,6 +371,12 @@ func rolePolicySnapshotForUser(st *store.Store, userID string) (extension.RolePo
snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion,
UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled,
AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}}
linked, err := authzsvc.NewService(st).EffectiveLinkedCapabilities(account.Owner)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
snapshot.Linked = extension.LinkedCapabilities{ProjectCreate: linked.ProjectCreate, Source: linked.Source,
Actions: linked.LinkedActions, Conflicts: linked.Conflicts}
view, err := st.RoleForUser(userID)
if err != nil {
return extension.RolePolicySnapshot{}, err
@@ -456,6 +462,64 @@ func (h inprocHost) CheckCurrentSystemAction(ctx context.Context, actionID strin
AuthorityEpoch: decision.AuthorityEpoch, Reason: decision.Reason}, nil
}
func (h inprocHost) currentCapabilityExplanation(ctx context.Context) (store.CapabilityExplanation, extension.CapabilitySubject, error) {
if err := ctx.Err(); err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
if _, err := h.rolePolicyActor(); err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
explanation, err := authzsvc.NewService(h.caps.st).ExplainCapabilities(h.actor)
if err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
subject := extension.CapabilitySubject{SchemaVersion: extension.CapabilityExplainSchemaVersion,
UserID: explanation.UserID, Owner: explanation.Owner, AuthorityEpoch: explanation.AuthorityEpoch,
RoleID: explanation.RoleID, RoleName: explanation.RoleName, RoleEnabled: explanation.RoleEnabled}
return explanation, subject, nil
}
func capabilityActionExplains(in []store.CapabilityActionExplain) []extension.CapabilityActionExplain {
out := make([]extension.CapabilityActionExplain, 0, len(in))
for _, action := range in {
out = append(out, extension.CapabilityActionExplain(action))
}
return out
}
func (h inprocHost) CurrentCapabilityExplain(ctx context.Context) (extension.CapabilityExplain, error) {
explanation, subject, err := h.currentCapabilityExplanation(ctx)
if err != nil {
return extension.CapabilityExplain{}, err
}
capability := explanation.Capability
return extension.CapabilityExplain{CapabilitySubject: subject,
ProjectCreate: extension.CapabilityActionExplain(capability.ProjectCreate),
Linked: capabilityActionExplains(capability.Linked),
Conflicts: append([]string{}, capability.Conflicts...), MigrationNote: capability.MigrationNote}, nil
}
func (h inprocHost) CurrentMemberBasicCollaborate(ctx context.Context) (extension.MemberBasicCollaborate, error) {
explanation, subject, err := h.currentCapabilityExplanation(ctx)
if err != nil {
return extension.MemberBasicCollaborate{}, err
}
return extension.MemberBasicCollaborate{CapabilitySubject: subject,
Actions: capabilityActionExplains(explanation.MemberBasicCollaborate.Actions)}, nil
}
func (h inprocHost) CurrentDocumentQualify(ctx context.Context) (extension.DocumentQualify, error) {
explanation, subject, err := h.currentCapabilityExplanation(ctx)
if err != nil {
return extension.DocumentQualify{}, err
}
duties := make([]extension.DocumentDutyQualify, 0, len(explanation.DocumentQualify.Duties))
for _, duty := range explanation.DocumentQualify.Duties {
duties = append(duties, extension.DocumentDutyQualify(duty))
}
return extension.DocumentQualify{CapabilitySubject: subject, Duties: duties}, nil
}
func (h inprocHost) requireRoleAdminAction(actionID string) error {
if h.actor == "" || h.owner != h.actor {
return store.ErrAccountForbidden
+97
View File
@@ -0,0 +1,97 @@
package app
import (
"context"
"errors"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/capability-explain.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
role, err := st.CreateRole("admin", store.RoleCreate{Name: "项目负责人"})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []store.RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "document.read", Allowed: true, CapAllowed: true},
{ActionID: "document.review", Allowed: false, CapAllowed: true},
}, "explain-provider"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-provider-assign"); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"}
provider, ok := any(h).(extension.CapabilityExplainProvider)
if !ok {
t.Fatal("dispatch host missing CapabilityExplainProvider")
}
ctx := context.Background()
capability, err := provider.CurrentCapabilityExplain(ctx)
if err != nil {
t.Fatal(err)
}
if capability.SchemaVersion != extension.CapabilityExplainSchemaVersion || capability.UserID != alice.ID || capability.Owner != "alice" ||
capability.RoleID != role.RoleID || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != extension.CapabilitySourceRole ||
len(capability.Linked) != 4 || len(capability.Conflicts) != 0 {
t.Fatalf("capability=%+v", capability)
}
snapshot, err := any(h).(extension.RolePolicyProvider).CurrentRolePolicy(ctx)
if err != nil || snapshot.Linked.Source != capability.ProjectCreate.Source || len(snapshot.Linked.Actions) != len(capability.Linked) {
t.Fatalf("snapshot linked=%+v err=%v", snapshot.Linked, err)
}
member, err := provider.CurrentMemberBasicCollaborate(ctx)
if err != nil || member.UserID != alice.ID || len(member.Actions) == 0 {
t.Fatalf("member=%+v err=%v", member, err)
}
for _, action := range member.Actions {
if action.ActionID == "document.read" && (!action.Allowed || action.Source != extension.CapabilitySourceRole) {
t.Fatalf("document.read=%+v", action)
}
}
document, err := provider.CurrentDocumentQualify(ctx)
if err != nil || document.UserID != alice.ID || len(document.Duties) != 3 {
t.Fatalf("document=%+v err=%v", document, err)
}
for _, duty := range document.Duties {
if duty.ActionID == "document.review" && (!duty.Qualified || duty.DefaultAllowed) {
t.Fatalf("document.review=%+v", duty)
}
}
// A scoped Host without a dispatch actor cannot explain anyone.
scoped := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}).(extension.CapabilityExplainProvider)
if _, err := scoped.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("scoped capability: %v", err)
}
if _, err := scoped.CurrentMemberBasicCollaborate(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("scoped member: %v", err)
}
if _, err := scoped.CurrentDocumentQualify(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("scoped document: %v", err)
}
ghost := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "ghost", actor: "ghost"}).(extension.CapabilityExplainProvider)
if _, err := ghost.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("unknown actor: %v", err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if _, err := provider.CurrentCapabilityExplain(canceled); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled: %v", err)
}
}
+26
View File
@@ -65,3 +65,29 @@ func Catalog() []Descriptor {
copy(out, descriptors)
return out
}
// ProjectCreateAction is the system action whose holder manages the projects
// they create. Enabling it links ProjectCreateLinkedActions for those projects.
const ProjectCreateAction = "project.create"
var projectCreateLinkedActions = []string{
"project.settings",
"project.members.manage",
"project.permissions.manage",
"document.duties.assign",
}
// ProjectCreateLinkedActions returns the own-project management capabilities
// that a project creator receives together with project.create.
func ProjectCreateLinkedActions() []string {
return append([]string(nil), projectCreateLinkedActions...)
}
func IsProjectCreateLinkedAction(id string) bool {
for _, linked := range projectCreateLinkedActions {
if linked == id {
return true
}
}
return false
}
+20
View File
@@ -16,6 +16,26 @@ func TestCatalogRejectsUnknownAndProtectsManagementActions(t *testing.T) {
}
}
func TestProjectCreateLinkedActionsAreCatalogedProjectActions(t *testing.T) {
linked := ProjectCreateLinkedActions()
if len(linked) != 4 {
t.Fatalf("linked=%v", linked)
}
for _, actionID := range linked {
descriptor, ok := DescriptorByID(actionID)
if !ok || descriptor.Scope != ScopeProject || descriptor.Protected || !IsProjectCreateLinkedAction(actionID) {
t.Fatalf("linked action %s descriptor=%+v ok=%v", actionID, descriptor, ok)
}
}
if IsProjectCreateLinkedAction(ProjectCreateAction) || IsProjectCreateLinkedAction("document.read") {
t.Fatal("unlinked action reported as linked")
}
linked[0] = "mutated"
if ProjectCreateLinkedActions()[0] == "mutated" {
t.Fatal("linked actions exposed mutable storage")
}
}
func TestCatalogReturnsIndependentCopies(t *testing.T) {
first := Catalog()
if len(first) == 0 {
+19
View File
@@ -77,6 +77,25 @@ func (s *Service) AuthorizeSystem(actor, actionID string) Decision {
return decision
}
// EffectiveLinkedCapabilities returns the own-project management actions that
// the actor's project.create links. HTTP and command readers share this call
// so the link is computed once per actor from the Backend authority.
func (s *Service) EffectiveLinkedCapabilities(actor string) (store.CapabilityAssociation, error) {
if s == nil || s.store == nil {
return store.CapabilityAssociation{LinkedActions: []string{}}, store.ErrAccountForbidden
}
return s.store.LinkedCapabilities(actor)
}
// ExplainCapabilities explains only the actor's own creator, member and
// document-duty capabilities; HTTP and Host providers share this call.
func (s *Service) ExplainCapabilities(actor string) (store.CapabilityExplanation, error) {
if s == nil || s.store == nil {
return store.CapabilityExplanation{}, store.ErrAccountForbidden
}
return s.store.ExplainCapabilities(actor)
}
func (s *Service) EffectiveSystemActions(actor string) []string {
actions := []string{}
for _, descriptor := range catalog.Catalog() {
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"database/sql"
"database/sql/driver"
"errors"
"net/http"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// explainTargetParams would select another actor; the endpoint refuses them
// outright so the response never depends on whether that actor exists.
var explainTargetParams = []string{"owner", "userId", "user", "actor"}
func (s *Server) explainCapability(w http.ResponseWriter, r *http.Request) {
if SessionScope(r) == store.SessionScopeSetPasswordOnly {
http.Error(w, "capability explain denied", http.StatusForbidden)
return
}
query := r.URL.Query()
for _, key := range explainTargetParams {
if query.Has(key) {
http.Error(w, "capability explain is limited to the current actor", http.StatusForbidden)
return
}
}
explanation, err := s.authorizer.ExplainCapabilities(s.owner(r))
if errors.Is(err, store.ErrAccountForbidden) {
http.Error(w, "capability explain denied", http.StatusForbidden)
return
}
if err != nil {
http.Error(w, "capability explain unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, http.StatusOK, explanation)
}
// categorizeWorkFailure maps Backend and provider errors for operation into
// one WorkFailure category. Uncategorized errors, notably not-found, keep the
// existing handling so a refusal never reveals another project or account.
func categorizeWorkFailure(operation string, err error) (extension.WorkFailure, bool) {
var failure extension.WorkFailure
switch {
case err == nil:
return extension.WorkFailure{}, false
case errors.As(err, &failure) && failure.StatusCode() != 0:
return extension.CategorizeWorkFailure(operation, err)
case errors.Is(err, store.ErrAccountForbidden):
return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailurePolicyRejected}, true
case operation != extension.WorkOperationConversation && errors.Is(err, store.ErrConflict):
return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailureDuplicateName}, true
case errors.Is(err, sql.ErrConnDone), errors.Is(err, driver.ErrBadConn):
return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailureServiceUnavailable}, true
}
return extension.CategorizeWorkFailure(operation, err)
}
func writeWorkFailure(w http.ResponseWriter, operation string, err error) {
if failure, ok := categorizeWorkFailure(operation, err); ok {
http.Error(w, failure.Error(), failure.StatusCode())
return
}
writeProjectErr(w, err)
}
func writeWorkJSON(w http.ResponseWriter, operation string, v any, err error) {
if err != nil {
writeWorkFailure(w, operation, err)
return
}
writeJSON(w, http.StatusOK, v)
}
+372
View File
@@ -0,0 +1,372 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// leakyStatusError carries provider detail that must never reach a client.
type leakyStatusError int
func (e leakyStatusError) Error() string { return "secret-project of other-owner already exists" }
func (e leakyStatusError) StatusCode() int { return int(e) }
const leakMarker = "other-owner"
type capabilityExplainHTTP struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
RoleID string `json:"roleId"`
Capability struct {
ProjectCreate extension.CapabilityActionExplain `json:"projectCreate"`
Linked []extension.CapabilityActionExplain `json:"linked"`
Conflicts []string `json:"conflicts"`
MigrationNote string `json:"migrationNote"`
} `json:"capability"`
MemberBasicCollaborate struct {
Actions []extension.CapabilityActionExplain `json:"actions"`
} `json:"memberBasicCollaborate"`
DocumentQualify struct {
Duties []extension.DocumentDutyQualify `json:"duties"`
} `json:"documentQualify"`
}
func explainRoleFixture(t *testing.T, h http.Handler, st *store.Store, admin, owner, name, changes string) (store.Account, string) {
t.Helper()
if err := st.CreateAccount(owner, owner, "", false); err != nil {
t.Fatal(err)
}
account, _ := st.GetAccountByOwner(owner)
key, err := st.CreateAPIKey(owner, owner+"-explain")
if err != nil {
t.Fatal(err)
}
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"policy-`+owner+`","changes":`+changes+`}`, admin)
if policy.Code != http.StatusOK {
t.Fatalf("policy: %d %s", policy.Code, policy.Body.String())
}
assign := do(h, "PUT", "/api/admin/users/"+account.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"idempotencyKey":"assign-`+owner+`"}`, admin)
if assign.Code != http.StatusOK {
t.Fatalf("assign: %d %s", assign.Code, assign.Body.String())
}
return account, key
}
func decodeCapabilityExplain(t *testing.T, rec *httptest.ResponseRecorder) capabilityExplainHTTP {
t.Helper()
var out capabilityExplainHTTP
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil {
t.Fatalf("explain: %d %s", rec.Code, rec.Body.String())
}
return out
}
func TestCapabilityExplainHTTPReturnsCreatorSourceAndLinkedActions(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
creator, key := explainRoleFixture(t, h, st, admin, "creator", "项目创建者",
`[{"actionId":"project.create","allowed":true,"capAllowed":true}]`)
rec := bearerRequest(h, "GET", "/api/capability/explain", "", key)
body := decodeCapabilityExplain(t, rec)
if body.Owner != "creator" || body.UserID != creator.ID || body.RoleID == "" {
t.Fatalf("subject=%+v", body)
}
create := body.Capability.ProjectCreate
if !create.Allowed || create.Source != extension.CapabilitySourceRole || create.Scope != extension.CapabilityScopeSystem {
t.Fatalf("project.create=%+v", create)
}
linked := []string{}
for _, action := range body.Capability.Linked {
if !action.Allowed || action.Source != "role" || action.LinkedVia != "project.create" || action.Scope != "own-projects" {
t.Fatalf("linked=%+v", action)
}
linked = append(linked, action.ActionID)
}
if strings.Join(linked, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" ||
len(body.Capability.Conflicts) != 0 || body.Capability.MigrationNote != "" {
t.Fatalf("capability=%+v", body.Capability)
}
// The /api/me linked view and the explanation agree on one snapshot.
me := bearerRequest(h, "GET", "/api/me", "", key)
if !strings.Contains(me.Body.String(), `"source":"role"`) {
t.Fatalf("me: %s", me.Body.String())
}
if strings.Contains(rec.Body.String(), `"admin"`) {
t.Fatalf("explanation named another account: %s", rec.Body.String())
}
}
func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
_, key := explainRoleFixture(t, h, st, admin, "member", "协作成员", `[
{"actionId":"document.read","allowed":true,"capAllowed":true},
{"actionId":"trace.edit","allowed":false,"capAllowed":true},
{"actionId":"document.author","allowed":true,"capAllowed":true},
{"actionId":"document.approve","allowed":false,"capAllowed":false}]`)
body := decodeCapabilityExplain(t, bearerRequest(h, "GET", "/api/capability/explain", "", key))
if body.Capability.ProjectCreate.Allowed || body.Capability.ProjectCreate.Reason != "not-granted" {
t.Fatalf("member project.create=%+v", body.Capability.ProjectCreate)
}
member := map[string]extension.CapabilityActionExplain{}
for _, action := range body.MemberBasicCollaborate.Actions {
member[action.ActionID] = action
}
if read := member["document.read"]; !read.Allowed || read.Source != "role" || read.Scope != "member-projects" {
t.Fatalf("document.read=%+v", read)
}
if trace := member["trace.edit"]; trace.Allowed || !trace.CapAllowed || trace.Reason != "role-default-deny" {
t.Fatalf("trace.edit=%+v", trace)
}
if audit := member["project.audit.read"]; audit.Allowed || audit.Reason != "action-unconfigured" {
t.Fatalf("project.audit.read=%+v", audit)
}
duties := map[string]extension.DocumentDutyQualify{}
for _, duty := range body.DocumentQualify.Duties {
duties[duty.ActionID] = duty
}
if author := duties["document.author"]; !author.Qualified || !author.DefaultAllowed || author.Source != "role" {
t.Fatalf("document.author=%+v", author)
}
if review := duties["document.review"]; review.Qualified || review.Reason != "action-unconfigured" {
t.Fatalf("document.review=%+v", review)
}
if approve := duties["document.approve"]; approve.Qualified || approve.Reason != "role-cap" {
t.Fatalf("document.approve=%+v", approve)
}
}
func TestUnauthorizedExplainReturnsNoLeakage(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
alice, _ := explainRoleFixture(t, h, st, admin, "alice", "项目创建者",
`[{"actionId":"project.create","allowed":true,"capAllowed":true}]`)
if _, err := st.CreateProject("alice", "alice-secret-project"); err != nil {
t.Fatal(err)
}
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "bob-explain")
if err != nil {
t.Fatal(err)
}
noLeak := func(label string, rec *httptest.ResponseRecorder) {
t.Helper()
for _, secret := range []string{"alice", alice.ID, "alice-secret-project"} {
if strings.Contains(rec.Body.String(), secret) || strings.Contains(strings.Join(rec.Header().Values("X-Owner"), ","), secret) {
t.Fatalf("%s leaked %q: %d %s", label, secret, rec.Code, rec.Body.String())
}
}
}
// Naming another actor is refused identically whether it exists or not.
var refused string
for _, path := range []string{"/api/capability/explain?owner=alice", "/api/capability/explain?owner=nobody",
"/api/capability/explain?userId=" + alice.ID, "/api/capability/explain?userId=user_missing",
"/api/capability/explain?actor=alice", "/api/capability/explain?user=bob"} {
rec := bearerRequest(h, "GET", path, "", bobKey)
if rec.Code != http.StatusForbidden {
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String())
}
noLeak(path, rec)
if refused == "" {
refused = rec.Body.String()
} else if rec.Body.String() != refused {
t.Fatalf("%s distinguishable refusal %q vs %q", path, rec.Body.String(), refused)
}
}
// A set-password-only session cannot read capability explanations.
if err := st.SetUserPassword("bob", mustHash(t, "bob-temp-pass")); err != nil {
t.Fatal(err)
}
restricted, err := st.CreateSessionScoped("bob", store.SessionScopeSetPasswordOnly, time.Hour)
if err != nil {
t.Fatal(err)
}
if rec := do(h, "GET", "/api/capability/explain", "", restricted); rec.Code != http.StatusForbidden {
t.Fatalf("restricted session: %d %s", rec.Code, rec.Body.String())
} else {
noLeak("restricted", rec)
}
// Missing, invalid and disabled credentials stop before any explanation.
if rec := do(h, "GET", "/api/capability/explain", "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous: %d", rec.Code)
} else {
noLeak("anonymous", rec)
}
if rec := bearerRequest(h, "GET", "/api/capability/explain", "", "ak_invalid"); rec.Code != http.StatusUnauthorized {
t.Fatalf("invalid key: %d", rec.Code)
} else {
noLeak("invalid", rec)
}
bob, _ := st.GetAccountByOwner("bob")
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "GET", "/api/capability/explain", "", bobKey); rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
t.Fatalf("disabled: %d %s", rec.Code, rec.Body.String())
} else {
noLeak("disabled", rec)
}
// Defense in depth: the handler itself refuses an unavailable actor.
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/capability/explain", nil)
srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "bob")))
if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" {
t.Fatalf("handler disabled actor: %d %q", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "ghost")))
if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" {
t.Fatalf("handler missing actor: %d %q", rec.Code, rec.Body.String())
}
}
func mustHash(t *testing.T, password string) string {
t.Helper()
hash, err := store.HashPassword(password)
if err != nil {
t.Fatal(err)
}
return hash
}
func TestCreateErrorCategorization(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
grantProjectCreateFixture(t, st, "alice")
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects", `{"name":"x"}`); w.Code != http.StatusForbidden {
t.Fatalf("unauthorized create=%d %s", w.Code, w.Body.String())
}
cases := []struct {
name string
err error
code int
body string
}{
{"duplicate", extension.WorkFailure{Operation: "rdm", Reason: extension.WorkFailureDuplicateName}, 409, "project.create: duplicate-name\n"},
{"coded-duplicate", fmtWrap(leakyStatusError(409)), 409, "project.create: duplicate-name\n"},
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "project.create: policy-rejected\n"},
{"identity", store.ErrAccountForbidden, 403, "project.create: policy-rejected\n"},
{"mapping", fmtWrap(extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}), 503, "project.create: mapping-not-ready\n"},
{"unavailable", leakyStatusError(503), 503, "project.create: service-unavailable\n"},
{"deadline", context.DeadlineExceeded, 503, "project.create: service-unavailable\n"},
}
for _, tc := range cases {
err := tc.err
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
return core.Project{}, err
}}, nil)
w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String())
}
}
s.projectLifecycle = nil
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"ok"}`); w.Code != http.StatusOK {
t.Fatalf("store create=%d %s", w.Code, w.Body.String())
}
}
func TestMemberAddErrorCategorization(t *testing.T) {
op := extension.WorkOperationMemberAdd
cases := []struct {
name string
err error
code int
body string
}{
{"duplicate", extension.WorkFailure{Reason: extension.WorkFailureDuplicateName}, 409, "project.member.add: duplicate-name\n"},
{"store-conflict", store.ErrConflict, 409, "project.member.add: duplicate-name\n"},
{"coded-conflict", leakyStatusError(409), 409, "project.member.add: duplicate-name\n"},
{"identity", store.ErrAccountForbidden, 403, "project.member.add: policy-rejected\n"},
{"policy", fmtWrap(leakyStatusError(403)), 403, "project.member.add: policy-rejected\n"},
{"mapping", extension.WorkFailure{Operation: op, Reason: extension.WorkFailureMappingNotReady}, 503, "project.member.add: mapping-not-ready\n"},
{"unavailable", extension.WorkFailure{Reason: extension.WorkFailureServiceUnavailable}, 503, "project.member.add: service-unavailable\n"},
{"conn-done", sql.ErrConnDone, 503, "project.member.add: service-unavailable\n"},
// Unknown targets stay a plain not-found, indistinguishable from absence.
{"not-found", store.ErrNotFound, 404, "store: not found\n"},
}
for _, tc := range cases {
w := httptest.NewRecorder()
writeWorkFailure(w, op, tc.err)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String())
}
}
if _, ok := categorizeWorkFailure(op, extension.WorkFailure{Reason: "invented"}); ok {
t.Fatal("unknown reason categorized")
}
if failure, ok := extension.CategorizeWorkFailure(op, errors.Join(errors.New("rdm"), extension.WorkFailure{Operation: "other", Reason: extension.WorkFailurePolicyRejected})); !ok ||
failure.Operation != op || failure.StatusCode() != http.StatusForbidden {
t.Fatalf("provider categorization=%+v ok=%v", failure, ok)
}
}
func TestConversationWorkErrorCategorization(t *testing.T) {
s, st := newAuthTestServer(t)
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
p, _ := st.CreateProject("alice", "Shared")
var workErr error
member := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
return member, workErr
}}, nil)
cases := []struct {
name string
err error
code int
body string
}{
{"mapping", extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}, 503, "conversation.work: mapping-not-ready\n"},
{"unavailable", fmtWrap(leakyStatusError(503)), 503, "conversation.work: service-unavailable\n"},
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "conversation.work: policy-rejected\n"},
{"identity", store.ErrAccountForbidden, 403, "conversation.work: policy-rejected\n"},
}
for _, tc := range cases {
workErr = tc.err
for _, q := range []struct{ method, path, body string }{
{"POST", "/api/projects/" + p.ID + "/conversations", `{"title":"t"}`},
{"GET", "/api/projects/" + p.ID + "/conversations", ""},
} {
w := lifecycleRequest(t, s, bob, q.method, q.path, q.body)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("%s %s %s: %d %q", tc.name, q.method, q.path, w.Code, w.Body.String())
}
}
}
// A non-member and an unknown project are both plain not-found.
workErr, member = nil, false
for _, path := range []string{"/api/projects/" + p.ID + "/conversations", "/api/projects/prj_missing/conversations"} {
if w := lifecycleRequest(t, s, bob, "POST", path, `{}`); w.Code != http.StatusNotFound || strings.Contains(w.Body.String(), "alice") {
t.Fatalf("nonmember %s: %d %q", path, w.Code, w.Body.String())
}
}
// Conversation work has no names, so a conflict is never a duplicate name.
if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, store.ErrConflict); ok {
t.Fatal("conversation conflict categorized as duplicate name")
}
if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, leakyStatusError(409)); ok {
t.Fatal("conversation coded conflict categorized as duplicate name")
}
}
@@ -55,6 +55,60 @@ func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) {
}
}
func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("creator", "Creator", "", false); err != nil {
t.Fatal(err)
}
creator, _ := st.GetAccountByOwner("creator")
key, err := st.CreateAPIKey("creator", "creator-test")
if err != nil {
t.Fatal(err)
}
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"项目创建者"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
conflict := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"create-conflict","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true},{"actionId":"project.members.manage","allowed":false,"capAllowed":false}]}`, admin)
if conflict.Code != http.StatusBadRequest {
t.Fatalf("linked conflict accepted: %d %s", conflict.Code, conflict.Body.String())
}
stale := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if stale.Code != http.StatusConflict {
t.Fatalf("stale role version: %d %s", stale.Code, stale.Body.String())
}
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if policy.Code != http.StatusOK || !strings.Contains(policy.Body.String(), `"linkedExpansion":["project.settings","project.members.manage","project.permissions.manage","document.duties.assign"]`) {
t.Fatalf("create-only policy: %d %s", policy.Code, policy.Body.String())
}
assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin)
if assign.Code != http.StatusOK {
t.Fatalf("assign creator: %d %s", assign.Code, assign.Body.String())
}
me := bearerRequest(h, "GET", "/api/me", "", key)
var body struct {
Linked struct {
ProjectCreate bool `json:"projectCreate"`
Source string `json:"source"`
LinkedActions []string `json:"linkedActions"`
} `json:"linkedCapabilities"`
}
if me.Code != http.StatusOK || json.Unmarshal(me.Body.Bytes(), &body) != nil {
t.Fatalf("me: %d %s", me.Code, me.Body.String())
}
if !body.Linked.ProjectCreate || body.Linked.Source != "role" ||
strings.Join(body.Linked.LinkedActions, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" {
t.Fatalf("linked capabilities=%+v", body.Linked)
}
}
func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
+18 -6
View File
@@ -338,6 +338,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants)
mux.HandleFunc("PUT /api/admin/users/{userId}/system-grants", s.replaceSystemGrants)
mux.HandleFunc("GET /api/authz/explain", s.explainSystemAuthorization)
mux.HandleFunc("GET /api/capability/explain", s.explainCapability)
// ---- 项目 / 会话 ----
mux.HandleFunc("GET /api/projects", s.listProjects)
@@ -766,11 +767,11 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
return
}
p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name))
writeProjectJSON(w, s.publicProjectMetadata(p), err)
writeWorkJSON(w, extension.WorkOperationProjectCreate, s.publicProjectMetadata(p), err)
return
}
p, err := s.st.CreateProjectAccountChecked(s.owner(r), strings.TrimSpace(req.Name), s.Auth == nil || !s.Auth.Enabled)
writeJSONOr(w, p, err)
writeWorkJSON(w, extension.WorkOperationProjectCreate, p, err)
}
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
@@ -871,7 +872,7 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil {
writeProjectErr(w, err)
writeWorkFailure(w, extension.WorkOperationConversation, err)
return
}
} else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
@@ -912,6 +913,9 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
out["mustChangePassword"] = st.MustChangePassword
out["passwordSet"] = st.HasPassword
out["systemActions"] = s.authorizer.EffectiveSystemActions(owner)
if linked, err := s.authorizer.EffectiveLinkedCapabilities(owner); err == nil {
out["linkedCapabilities"] = linked
}
}
writeJSON(w, http.StatusOK, out)
}
@@ -926,7 +930,7 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
req.Title = "新会话"
}
c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
writeProjectJSON(w, c, err)
writeWorkJSON(w, extension.WorkOperationConversation, c, err)
}
// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
@@ -1058,7 +1062,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
writeProjectErr(w, err)
writeWorkFailure(w, extension.WorkOperationConversation, err)
return
}
var req struct {
@@ -1352,7 +1356,7 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
} else {
hostProject = string(conv.ProjectID)
if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil {
writeProjectErr(w, err)
writeWorkFailure(w, extension.WorkOperationConversation, err)
return
}
}
@@ -2415,6 +2419,14 @@ func writeStoreErr(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, store.ErrRolePolicyVersion) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if errors.Is(err, store.ErrRolePolicyRoleMissing) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if errors.Is(err, store.ErrInvalidRolePolicy) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
+224
View File
@@ -0,0 +1,224 @@
package store
import (
"database/sql"
"errors"
"strings"
"agent-system-backend/internal/authz/catalog"
)
const (
CapabilitySourceRole = "role"
CapabilitySourceSystemGrant = "system-grant"
CapabilitySourceBootstrapAdmin = "bootstrap-admin"
CapabilityScopeSystem = "system"
CapabilityScopeOwnProjects = "own-projects"
CapabilityScopeMemberProjects = "member-projects"
)
// memberBasicCollaborateActions are the role defaults a member brings into a
// project; documentQualifyDuties are the three assignable document duties.
var (
memberBasicCollaborateActions = []string{"document.read", "trace.edit", "knowledge.publish", "project.audit.read"}
documentQualifyDuties = []string{"document.author", "document.review", "document.approve"}
)
type CapabilitySubject struct {
UserID string `json:"userId"`
Owner string `json:"owner"`
AuthorityEpoch int64 `json:"authorityEpoch"`
RoleID string `json:"roleId,omitempty"`
RoleName string `json:"roleName,omitempty"`
RoleEnabled bool `json:"roleEnabled"`
}
type CapabilityActionExplain struct {
ActionID string `json:"actionId"`
Scope string `json:"scope"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
Source string `json:"source,omitempty"`
LinkedVia string `json:"linkedVia,omitempty"`
Conflict bool `json:"conflict,omitempty"`
Reason string `json:"reason,omitempty"`
}
type CapabilityExplain struct {
ProjectCreate CapabilityActionExplain `json:"projectCreate"`
Linked []CapabilityActionExplain `json:"linked"`
Conflicts []string `json:"conflicts"`
MigrationNote string `json:"migrationNote,omitempty"`
}
type MemberBasicCollaborate struct {
Actions []CapabilityActionExplain `json:"actions"`
}
type DocumentDutyQualify struct {
ActionID string `json:"actionId"`
Qualified bool `json:"qualified"`
DefaultAllowed bool `json:"defaultAllowed"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
type DocumentQualify struct {
Duties []DocumentDutyQualify `json:"duties"`
}
// CapabilityExplanation is one actor's creator, member and document-duty
// explanation computed from a single authority snapshot.
type CapabilityExplanation struct {
CapabilitySubject
Capability CapabilityExplain `json:"capability"`
MemberBasicCollaborate MemberBasicCollaborate `json:"memberBasicCollaborate"`
DocumentQualify DocumentQualify `json:"documentQualify"`
}
func (s *Store) rolePermissionsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) {
rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]RolePermissionChange{}
for rows.Next() {
var permission RolePermissionChange
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil {
return nil, err
}
out[permission.ActionID] = permission
}
return out, rows.Err()
}
// roleActionExplain uses the same reason vocabulary as the Host role check.
func roleActionExplain(actionID, scope string, bound, enabled bool, permissions map[string]RolePermissionChange) CapabilityActionExplain {
out := CapabilityActionExplain{ActionID: actionID, Scope: scope}
if !bound {
out.Reason = "role-unassigned"
return out
}
if !enabled {
out.Reason = "role-disabled"
return out
}
permission, ok := permissions[actionID]
switch {
case !ok:
out.Reason = "action-unconfigured"
case !permission.CapAllowed:
out.Reason = "role-cap"
case !permission.Allowed:
out.CapAllowed, out.Reason = true, "role-default-deny"
default:
out.Allowed, out.CapAllowed, out.Source = true, true, CapabilitySourceRole
}
return out
}
func capabilityExplainNote(projectCreate bool, conflicts, kept []string) string {
if projectCreate && len(conflicts) > 0 {
return "project.create linked actions capped by the enabled role: " + strings.Join(conflicts, ",") +
"; the cap wins on created projects until the role row is changed explicitly"
}
if !projectCreate && len(kept) > 0 {
return "project.create not held: linked capability rows " + strings.Join(kept, ",") +
" remain on the role; remove them explicitly to revoke own-project management"
}
return ""
}
// ExplainCapabilities explains the owner's own capabilities. A missing or
// disabled account is ErrAccountForbidden so callers cannot probe presence.
func (s *Store) ExplainCapabilities(owner string) (CapabilityExplanation, error) {
out := CapabilityExplanation{}
if owner == "" {
return out, ErrAccountForbidden
}
tx, err := s.db.Begin()
if err != nil {
return out, err
}
defer tx.Rollback()
if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil {
return out, err
}
var enabled, isAdmin bool
err = tx.QueryRow(s.q(`SELECT id,owner,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&out.UserID, &out.Owner, &enabled, &isAdmin)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) {
return CapabilityExplanation{}, ErrAccountForbidden
}
if err != nil {
return CapabilityExplanation{}, err
}
state, err := s.loadCapabilityAssociationTx(tx, []string{out.UserID}, nil)
if err != nil {
return CapabilityExplanation{}, err
}
roleID, bound := state.bindings[out.UserID]
permissions := map[string]RolePermissionChange{}
if bound {
role, err := s.roleTx(tx, roleID, false)
if err != nil {
return CapabilityExplanation{}, err
}
out.RoleID, out.RoleName, out.RoleEnabled = role.RoleID, role.Name, role.Enabled
if permissions, err = s.rolePermissionsTx(tx, roleID); err != nil {
return CapabilityExplanation{}, err
}
}
var enabledPermissions map[string]RolePermissionChange
if out.RoleEnabled {
enabledPermissions = permissions
}
create := roleActionExplain(catalog.ProjectCreateAction, CapabilityScopeSystem, bound, out.RoleEnabled, permissions)
switch {
case isAdmin:
create.Allowed, create.Source, create.Reason = true, CapabilitySourceBootstrapAdmin, ""
case state.creators[out.UserID]:
create.Allowed, create.Source, create.Reason = true, CapabilitySourceSystemGrant, ""
case create.Allowed:
case create.Reason == "role-unassigned" || create.Reason == "action-unconfigured":
create.Reason = "not-granted"
}
capability := CapabilityExplain{ProjectCreate: create, Linked: []CapabilityActionExplain{}, Conflicts: []string{}}
kept := []string{}
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if _, exists := permissions[actionID]; exists {
kept = append(kept, actionID)
}
linked := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeOwnProjects}
switch {
case !create.Allowed:
linked.Reason = "project-create-not-held"
case linkedActionDenied(enabledPermissions, actionID):
linked.LinkedVia, linked.Conflict, linked.Reason = catalog.ProjectCreateAction, true, "role-cap"
capability.Conflicts = append(capability.Conflicts, actionID)
default:
linked.Allowed, linked.CapAllowed, linked.Source, linked.LinkedVia = true, true, create.Source, catalog.ProjectCreateAction
}
capability.Linked = append(capability.Linked, linked)
}
capability.MigrationNote = capabilityExplainNote(create.Allowed, capability.Conflicts, kept)
out.Capability = capability
out.MemberBasicCollaborate.Actions = make([]CapabilityActionExplain, 0, len(memberBasicCollaborateActions))
for _, actionID := range memberBasicCollaborateActions {
out.MemberBasicCollaborate.Actions = append(out.MemberBasicCollaborate.Actions,
roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions))
}
out.DocumentQualify.Duties = make([]DocumentDutyQualify, 0, len(documentQualifyDuties))
for _, actionID := range documentQualifyDuties {
decision := roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions)
duty := DocumentDutyQualify{ActionID: actionID, Qualified: decision.CapAllowed, DefaultAllowed: decision.Allowed, Reason: decision.Reason}
if duty.Qualified {
duty.Source = CapabilitySourceRole
}
out.DocumentQualify.Duties = append(out.DocumentQualify.Duties, duty)
}
return out, tx.Commit()
}
+269
View File
@@ -0,0 +1,269 @@
package store
import (
"errors"
"strings"
"testing"
)
func explainActionMap(actions []CapabilityActionExplain) map[string]CapabilityActionExplain {
out := map[string]CapabilityActionExplain{}
for _, action := range actions {
out[action.ActionID] = action
}
return out
}
func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "explain-create"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-assign"); err != nil {
t.Fatal(err)
}
epoch, _ := st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "explain-grant"); err != nil {
t.Fatal(err)
}
for owner, source := range map[string]string{"alice": "role", "bob": "system-grant", "admin": "bootstrap-admin"} {
explanation, err := st.ExplainCapabilities(owner)
if err != nil {
t.Fatalf("%s: %v", owner, err)
}
linked, err := st.LinkedCapabilities(owner)
if err != nil {
t.Fatal(err)
}
capability := explanation.Capability
if explanation.Owner != owner || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != source ||
capability.ProjectCreate.Source != linked.Source || capability.ProjectCreate.Scope != CapabilityScopeSystem ||
len(capability.Conflicts) != 0 || capability.MigrationNote != "" || explanation.AuthorityEpoch != linked.AuthorityEpoch {
t.Fatalf("%s explanation=%+v linked=%+v", owner, explanation, linked)
}
allowed := []string{}
for _, action := range capability.Linked {
if !action.Allowed || action.Source != source || action.LinkedVia != "project.create" || action.Scope != CapabilityScopeOwnProjects {
t.Fatalf("%s linked action=%+v", owner, action)
}
allowed = append(allowed, action.ActionID)
}
if strings.Join(allowed, ",") != strings.Join(linked.LinkedActions, ",") {
t.Fatalf("%s explain linked=%v association=%v", owner, allowed, linked.LinkedActions)
}
}
alicePolicy, _ := st.ExplainCapabilities("alice")
if alicePolicy.RoleID != role.RoleID || alicePolicy.RoleName != "项目负责人" || !alicePolicy.RoleEnabled {
t.Fatalf("alice subject=%+v", alicePolicy.CapabilitySubject)
}
}
func TestExplainCapabilityReportsConflictsAndMigrationNote(t *testing.T) {
st := openTestStore(t)
admin, alice, _ := rolePolicyAccounts(t, st)
// Bootstrap admin keeps project.create through is_admin even without the
// grant, so a role cap on a linked action is reported as a conflict.
epoch, _ := st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, []string{"system.permissions.manage", "system.roles.manage", "system.roles.assign"}, "drop-admin-create"); err != nil {
t.Fatal(err)
}
capped, err := st.CreateRole("admin", RoleCreate{Name: "受限管理"})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
[]RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); err != nil {
t.Fatal(err)
}
admin, _ = st.GetAccount(admin.ID)
if _, err := st.AssignUserRole("admin", admin.ID, capped.RoleID, admin.StateVersion, "assign-admin-capped"); err != nil {
t.Fatal(err)
}
explanation, err := st.ExplainCapabilities("admin")
if err != nil {
t.Fatal(err)
}
linked, _ := st.LinkedCapabilities("admin")
capability := explanation.Capability
if capability.ProjectCreate.Source != "bootstrap-admin" || strings.Join(capability.Conflicts, ",") != "project.members.manage" ||
strings.Join(capability.Conflicts, ",") != strings.Join(linked.Conflicts, ",") {
t.Fatalf("conflicts=%+v linked=%+v", capability, linked)
}
members := explainActionMap(capability.Linked)["project.members.manage"]
if members.Allowed || !members.Conflict || members.Reason != "role-cap" || members.Source != "" {
t.Fatalf("conflicting linked action=%+v", members)
}
if !strings.Contains(capability.MigrationNote, "project.members.manage") {
t.Fatalf("conflict note=%q", capability.MigrationNote)
}
// Disabling project.create keeps linked rows; the explanation says so.
creator, err := st.CreateRole("admin", RoleCreate{Name: "创建者"})
if err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", creator.RoleID, creator.RoleVersion, []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "project.settings", Allowed: true, CapAllowed: true},
}, "enable-create")
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, creator.RoleID, alice.StateVersion, "assign-alice-creator"); err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", creator.RoleID, receipt.RoleVersion,
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create"); err != nil {
t.Fatal(err)
}
disabled, err := st.ExplainCapabilities("alice")
if err != nil {
t.Fatal(err)
}
if disabled.Capability.ProjectCreate.Allowed || disabled.Capability.ProjectCreate.Reason != "not-granted" {
t.Fatalf("disabled create=%+v", disabled.Capability.ProjectCreate)
}
for _, action := range disabled.Capability.Linked {
if action.Allowed || action.Reason != "project-create-not-held" {
t.Fatalf("linked after disable=%+v", action)
}
}
if note := disabled.Capability.MigrationNote; !strings.Contains(note, "project.create not held") || !strings.Contains(note, "project.settings") {
t.Fatalf("migration note=%q", note)
}
}
func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "协作成员"})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
{ActionID: "document.read", Allowed: true, CapAllowed: true},
{ActionID: "trace.edit", Allowed: false, CapAllowed: true},
{ActionID: "knowledge.publish", Allowed: false, CapAllowed: false},
}, "member-policy"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "member-assign"); err != nil {
t.Fatal(err)
}
explanation, err := st.ExplainCapabilities("alice")
if err != nil {
t.Fatal(err)
}
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
want := map[string]CapabilityActionExplain{
"document.read": {ActionID: "document.read", Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"},
"trace.edit": {ActionID: "trace.edit", Scope: CapabilityScopeMemberProjects, CapAllowed: true, Reason: "role-default-deny"},
"knowledge.publish": {ActionID: "knowledge.publish", Scope: CapabilityScopeMemberProjects, Reason: "role-cap"},
"project.audit.read": {ActionID: "project.audit.read", Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"},
}
if len(actions) != len(want) {
t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions)
}
for actionID, expected := range want {
if actions[actionID] != expected {
t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], expected)
}
}
unassigned, err := st.ExplainCapabilities("bob")
if err != nil {
t.Fatal(err)
}
for _, action := range unassigned.MemberBasicCollaborate.Actions {
if action.Allowed || action.Reason != "role-unassigned" {
t.Fatalf("unassigned member action=%+v", action)
}
}
// Bootstrap admin is a system source only; it grants no member content.
adminExplain, err := st.ExplainCapabilities("admin")
if err != nil {
t.Fatal(err)
}
for _, action := range adminExplain.MemberBasicCollaborate.Actions {
if action.Allowed || action.Source != "" {
t.Fatalf("admin member action=%+v", action)
}
}
}
func TestExplainCapabilityDocumentQualify(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "文档职责"})
if err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
{ActionID: "document.author", Allowed: true, CapAllowed: true},
{ActionID: "document.review", Allowed: false, CapAllowed: true},
{ActionID: "document.approve", Allowed: false, CapAllowed: false},
}, "duty-policy")
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "duty-assign"); err != nil {
t.Fatal(err)
}
explanation, err := st.ExplainCapabilities("alice")
if err != nil {
t.Fatal(err)
}
got := map[string]DocumentDutyQualify{}
for _, duty := range explanation.DocumentQualify.Duties {
got[duty.ActionID] = duty
}
want := map[string]DocumentDutyQualify{
"document.author": {ActionID: "document.author", Qualified: true, DefaultAllowed: true, Source: "role"},
"document.review": {ActionID: "document.review", Qualified: true, Source: "role", Reason: "role-default-deny"},
"document.approve": {ActionID: "document.approve", Reason: "role-cap"},
}
if len(got) != len(want) {
t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties)
}
for actionID, expected := range want {
if got[actionID] != expected {
t.Fatalf("%s=%+v want %+v", actionID, got[actionID], expected)
}
}
disabled := false
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
explanation, err = st.ExplainCapabilities("alice")
if err != nil {
t.Fatal(err)
}
for _, duty := range explanation.DocumentQualify.Duties {
if duty.Qualified || duty.Reason != "role-disabled" {
t.Fatalf("disabled role duty=%+v", duty)
}
}
}
func TestExplainCapabilityRejectsMissingAndDisabledAccountsAlike(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
if err := st.CreateAccount("carol", "Carol", "hash", false); err != nil {
t.Fatal(err)
}
carol, _ := st.GetAccountByOwner("carol")
if _, err := st.SetAccountEnabled("admin", carol.ID, carol.StateVersion, false); err != nil {
t.Fatal(err)
}
for _, owner := range []string{"", "nobody", "carol"} {
explanation, err := st.ExplainCapabilities(owner)
if !errors.Is(err, ErrAccountForbidden) || explanation.UserID != "" || explanation.Owner != "" {
t.Fatalf("%q explanation=%+v err=%v", owner, explanation, err)
}
}
}
+339 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -16,6 +17,10 @@ import (
var (
ErrInvalidRolePolicy = errors.New("store: invalid role policy")
ErrLastPermissionManager = errors.New("store: final permission manager")
// Role permission writers reject a stale or missing role as an invalid
// policy; the wrapped conflict/not-found class keeps HTTP retry semantics.
ErrRolePolicyVersion = fmt.Errorf("%w: role version changed: %w", ErrInvalidRolePolicy, ErrConflict)
ErrRolePolicyRoleMissing = fmt.Errorf("%w: role missing: %w", ErrInvalidRolePolicy, ErrNotFound)
)
type RoleCreate struct {
@@ -52,6 +57,20 @@ type PolicyReceipt struct {
ReceiptID string `json:"receiptId"`
AuthorityEpoch int64 `json:"authorityEpoch"`
RoleVersion int64 `json:"roleVersion,omitempty"`
// LinkedExpansion lists linked rows the write added for project.create.
LinkedExpansion []string `json:"linkedExpansion,omitempty"`
MigrationNote string `json:"migrationNote,omitempty"`
}
// CapabilityAssociation is one actor's project.create link computed from a
// single authority snapshot. HTTP and command surfaces both read it here.
// Conflicts lists linked actions an enabled role still caps off.
type CapabilityAssociation struct {
ProjectCreate bool `json:"projectCreate"`
Source string `json:"source,omitempty"`
LinkedActions []string `json:"linkedActions"`
Conflicts []string `json:"conflicts,omitempty"`
AuthorityEpoch int64 `json:"authorityEpoch"`
}
// RoleRecord is the stable position definition stored by Backend.
@@ -359,25 +378,64 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
return err
}
role, err := s.roleTx(tx, roleID, true)
if errors.Is(err, ErrNotFound) {
return ErrRolePolicyRoleMissing
}
if err != nil {
return err
}
if role.RoleVersion != expected {
return ErrConflict
return ErrRolePolicyVersion
}
seen := map[string]bool{}
next := map[string]RolePermissionChange{}
for _, change := range changes {
descriptor, ok := catalog.DescriptorByID(change.ActionID)
if !ok || descriptor.Protected || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
return ErrInvalidRolePolicy
}
seen[change.ActionID] = true
next[change.ActionID] = change
}
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=?`), roleID); err != nil {
previous, err := s.loadCapabilityAssociationTx(tx, nil, []string{roleID})
if err != nil {
return err
}
wasCreator, isCreator := projectCreateEnabled(previous.roles[roleID]), projectCreateEnabled(next)
kept, expanded := []string{}, []string{}
rows := append([]RolePermissionChange(nil), changes...)
if wasCreator || isCreator {
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if seen[actionID] {
continue
}
if _, existed := previous.roles[roleID][actionID]; existed {
kept = append(kept, actionID)
} else if isCreator {
// The cap links management to the creator's own projects only;
// the default stays off for every other member project.
rows = append(rows, RolePermissionChange{ActionID: actionID, Allowed: false, CapAllowed: true})
expanded = append(expanded, actionID)
}
}
}
migrationNote := ""
if wasCreator && !isCreator {
migrationNote = projectCreateDisabledNote(kept)
}
deleteQuery := `DELETE FROM role_permissions WHERE role_id=?`
deleteArgs := []any{roleID}
if len(kept) > 0 {
deleteQuery += ` AND action_id NOT IN (` + strings.TrimSuffix(strings.Repeat("?,", len(kept)), ",") + `)`
for _, actionID := range kept {
deleteArgs = append(deleteArgs, actionID)
}
}
if _, err := tx.Exec(s.q(deleteQuery), deleteArgs...); err != nil {
return err
}
now := nowISO()
for _, change := range changes {
for _, change := range rows {
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, change.Allowed, change.CapAllowed, 1, actorAccount.ID, now); err != nil {
return err
@@ -388,12 +446,16 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
return err
}
if n, _ := result.RowsAffected(); n != 1 {
return ErrConflict
return ErrRolePolicyVersion
}
if err := s.checkCapabilityAssociationTx(tx, nil, []string{roleID}, nil); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1,
LinkedExpansion: expanded, MigrationNote: migrationNote}
return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, out)
})
return out, err
@@ -448,6 +510,9 @@ func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64
if n, _ := result.RowsAffected(); n != 1 {
return ErrConflict
}
if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
@@ -772,6 +837,22 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a
}
seen[actionID] = true
}
previous, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil)
if err != nil {
return err
}
migrationNote := ""
if previous.creators[userID] && !seen[catalog.ProjectCreateAction] {
kept := []string{}
if roleID, bound := previous.bindings[userID]; bound {
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if _, exists := previous.roles[roleID][actionID]; exists {
kept = append(kept, actionID)
}
}
}
migrationNote = projectCreateDisabledNote(kept)
}
if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=?`), userID); err != nil {
return err
}
@@ -790,10 +871,13 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a
if managers == 0 {
return ErrLastPermissionManager
}
if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, MigrationNote: migrationNote}
return s.saveReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, out)
})
return out, err
@@ -872,6 +956,22 @@ func (s *Store) ImportRoleAuthority(actor string, in RoleAuthorityImport, key st
if roleCount != 0 || bindingCount != 0 {
return RoleAuthorityImportResult{}, ErrConflict
}
pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}}
boundUsers := []string{}
for _, role := range roles {
permissions := map[string]RolePermissionChange{}
for _, permission := range role.Permissions {
permissions[permission.ActionID] = permission
}
pending.roles[role.RoleID] = permissions
for _, owner := range role.Owners {
pending.bindings[accounts[owner].ID] = role.RoleID
boundUsers = append(boundUsers, accounts[owner].ID)
}
}
if err := s.checkCapabilityAssociationTx(tx, boundUsers, nil, &pending); err != nil {
return RoleAuthorityImportResult{}, err
}
var epoch int64
query := `SELECT authority_epoch FROM authority_state WHERE id=1`
if s.Dialect() == "postgres" {
@@ -1029,6 +1129,13 @@ func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpo
importedUsers++
}
}
importedRoleIDs := make([]string, 0, len(staged.Roles))
for _, role := range staged.Roles {
importedRoleIDs = append(importedRoleIDs, role.RoleID)
}
if err := s.checkCapabilityAssociationTx(tx, nil, importedRoleIDs, nil); err != nil {
return RoleAuthorityImportResult{}, err
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return RoleAuthorityImportResult{}, err
}
@@ -1047,3 +1154,229 @@ func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpo
}
return RoleAuthorityImportResult{AuthorityEpoch: epoch + 1, ImportedRoles: len(staged.Roles), ImportedUsers: importedUsers}, nil
}
// capabilityAssociation is the slice of authority that decides whether
// project.create and its linked own-project management actions agree.
type capabilityAssociation struct {
roles map[string]map[string]RolePermissionChange // roleID -> actionID -> bits
bindings map[string]string // userID -> active roleID
creators map[string]bool // userID -> project.create system grant
}
func projectCreateEnabled(permissions map[string]RolePermissionChange) bool {
permission, ok := permissions[catalog.ProjectCreateAction]
return ok && permission.Allowed && permission.CapAllowed
}
// linkedActionDenied reports an explicit hard deny: the row caps the action
// off. An absent row is unconfigured and derived from project.create; a row
// with allowed=false under an open cap still serves the creator's own projects.
func linkedActionDenied(permissions map[string]RolePermissionChange, actionID string) bool {
permission, ok := permissions[actionID]
return ok && !permission.CapAllowed
}
func deniedLinkedAction(permissions map[string]RolePermissionChange) string {
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if linkedActionDenied(permissions, actionID) {
return actionID
}
}
return ""
}
func projectCreateDisabledNote(kept []string) string {
if len(kept) == 0 {
return "project.create disabled: no linked capability rows existed; nothing was deleted"
}
return "project.create disabled: kept linked capability rows " + strings.Join(kept, ",") +
"; remove them explicitly to revoke own-project management"
}
func associationActionArgs() []any {
args := []any{catalog.ProjectCreateAction}
for _, actionID := range catalog.ProjectCreateLinkedActions() {
args = append(args, actionID)
}
return args
}
// loadCapabilityAssociationTx reads the users' project.create grants and active
// roles plus, for each role, its bound project.create grant holders and the
// role rows for project.create and the linked actions.
func (s *Store) loadCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string) (capabilityAssociation, error) {
state := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{},
bindings: map[string]string{}, creators: map[string]bool{}}
roleIDs = append([]string(nil), roleIDs...)
for _, userID := range userIDs {
if _, loaded := state.creators[userID]; loaded {
continue
}
var allowed bool
err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), userID, catalog.ProjectCreateAction).Scan(&allowed)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return state, err
}
state.creators[userID] = allowed
var roleID string
err = tx.QueryRow(s.q(`SELECT role_id FROM user_role_bindings WHERE user_id=? AND active=?`), userID, true).Scan(&roleID)
switch {
case err == nil:
state.bindings[userID] = roleID
roleIDs = append(roleIDs, roleID)
case !errors.Is(err, sql.ErrNoRows):
return state, err
}
}
actionArgs := associationActionArgs()
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(actionArgs)), ",")
for _, roleID := range roleIDs {
if _, loaded := state.roles[roleID]; loaded {
continue
}
permissions := map[string]RolePermissionChange{}
rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=? AND action_id IN (`+placeholders+`)`),
append([]any{roleID}, actionArgs...)...)
if err != nil {
return state, err
}
for rows.Next() {
var permission RolePermissionChange
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil {
rows.Close()
return state, err
}
permissions[permission.ActionID] = permission
}
if err := rows.Close(); err != nil {
return state, err
}
state.roles[roleID] = permissions
holders, err := tx.Query(s.q(`SELECT b.user_id FROM user_role_bindings b
JOIN system_grants g ON g.user_id=b.user_id AND g.action_id=? AND g.allowed=?
WHERE b.role_id=? AND b.active=?`), catalog.ProjectCreateAction, true, roleID, true)
if err != nil {
return state, err
}
for holders.Next() {
var userID string
if err := holders.Scan(&userID); err != nil {
holders.Close()
return state, err
}
state.creators[userID] = true
state.bindings[userID] = roleID
}
if err := holders.Close(); err != nil {
return state, err
}
}
return state, nil
}
// checkCapabilityAssociationTx is the single in-transaction gate shared by all
// role authority writers. It rejects any role that enables project.create while
// denying a linked action, and any project.create grant holder whose active
// role denies one. pending overlays rows staged but not yet written.
func (s *Store) checkCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string, pending *capabilityAssociation) error {
state, err := s.loadCapabilityAssociationTx(tx, userIDs, roleIDs)
if err != nil {
return err
}
if pending != nil {
for roleID, permissions := range pending.roles {
state.roles[roleID] = permissions
}
for userID, roleID := range pending.bindings {
state.bindings[userID] = roleID
}
for userID, allowed := range pending.creators {
state.creators[userID] = allowed
}
}
roleIDs = roleIDs[:0:0]
for roleID := range state.roles {
roleIDs = append(roleIDs, roleID)
}
sort.Strings(roleIDs)
for _, roleID := range roleIDs {
permissions := state.roles[roleID]
if denied := deniedLinkedAction(permissions); denied != "" && projectCreateEnabled(permissions) {
return fmt.Errorf("%w: role %s enables project.create but denies %s", ErrInvalidRolePolicy, roleID, denied)
}
}
userIDs = userIDs[:0:0]
for userID, creator := range state.creators {
if creator {
userIDs = append(userIDs, userID)
}
}
sort.Strings(userIDs)
for _, userID := range userIDs {
roleID, bound := state.bindings[userID]
if !bound {
continue
}
if denied := deniedLinkedAction(state.roles[roleID]); denied != "" {
return fmt.Errorf("%w: project.create grant holder %s has role %s denying %s", ErrInvalidRolePolicy, userID, roleID, denied)
}
}
return nil
}
// LinkedCapabilities computes, from one authority snapshot, whether the actor
// holds project.create and which own-project management actions it links.
// An explicit deny on the actor's enabled role still wins over the link.
func (s *Store) LinkedCapabilities(owner string) (CapabilityAssociation, error) {
out := CapabilityAssociation{LinkedActions: []string{}}
tx, err := s.db.Begin()
if err != nil {
return out, err
}
defer tx.Rollback()
if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil {
return out, err
}
var userID string
var enabled, isAdmin bool
err = tx.QueryRow(s.q(`SELECT id,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&userID, &enabled, &isAdmin)
if errors.Is(err, sql.ErrNoRows) {
return out, ErrNotFound
}
if err != nil || !enabled {
return out, err
}
state, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil)
if err != nil {
return out, err
}
var rolePermissions map[string]RolePermissionChange
if roleID, bound := state.bindings[userID]; bound {
role, err := s.roleTx(tx, roleID, false)
if err != nil {
return out, err
}
if role.Enabled {
rolePermissions = state.roles[roleID]
}
}
switch {
case isAdmin:
out.Source = "bootstrap-admin"
case state.creators[userID]:
out.Source = "system-grant"
case projectCreateEnabled(rolePermissions):
out.Source = "role"
default:
return out, tx.Commit()
}
out.ProjectCreate = true
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if linkedActionDenied(rolePermissions, actionID) {
out.Conflicts = append(out.Conflicts, actionID)
continue
}
out.LinkedActions = append(out.LinkedActions, actionID)
}
return out, tx.Commit()
}
+285
View File
@@ -208,6 +208,291 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) {
}
}
func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[string]RolePermissionRecord {
t.Helper()
permissions, err := st.RolePermissions(roleID)
if err != nil {
t.Fatal(err)
}
out := map[string]RolePermissionRecord{}
for _, permission := range permissions {
out[permission.ActionID] = permission
}
return out
}
var linkedManagementActions = []string{"project.settings", "project.members.manage", "project.permissions.manage", "document.duties.assign"}
func TestCapabilityAssociationProjectCreateOnlyLinksFullManagement(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"})
if err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only")
if err != nil {
t.Fatal(err)
}
if strings.Join(receipt.LinkedExpansion, ",") != strings.Join(linkedManagementActions, ",") {
t.Fatalf("expansion=%v", receipt.LinkedExpansion)
}
permissions := capabilityRolePermissions(t, st, role.RoleID)
for _, actionID := range linkedManagementActions {
permission, ok := permissions[actionID]
// The cap opens the creator's own projects; the default stays off.
if !ok || permission.Allowed || !permission.CapAllowed {
t.Fatalf("%s=%+v ok=%v", actionID, permission, ok)
}
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil {
t.Fatal(err)
}
linked, err := st.LinkedCapabilities("alice")
if err != nil {
t.Fatal(err)
}
if !linked.ProjectCreate || linked.Source != "role" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") || len(linked.Conflicts) != 0 {
t.Fatalf("linked=%+v", linked)
}
if replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only"); err != nil || replayed.ReceiptID != receipt.ReceiptID || len(replayed.LinkedExpansion) != 4 {
t.Fatalf("replay=%+v err=%v", replayed, err)
}
}
func TestCapabilityAssociationRejectsExplicitConflictAtomically(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "受限创建者"})
if err != nil {
t.Fatal(err)
}
epoch, _ := st.AuthorityEpoch()
conflict := []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "project.settings", Allowed: false, CapAllowed: false},
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, conflict, "explicit-conflict"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("explicit role conflict: %v", err)
}
after, _ := st.GetRole(role.RoleID)
afterEpoch, _ := st.AuthorityEpoch()
if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 || after.RoleVersion != role.RoleVersion || afterEpoch != epoch {
t.Fatalf("partial write: role=%+v epoch=%d->%d", after, epoch, afterEpoch)
}
// A cap already closed on the role still blocks a later project.create.
capped := []RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, capped, "platform-cap")
if err != nil {
t.Fatal(err)
}
enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, enable, "enable-over-cap"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("existing platform cap conflict: %v", err)
}
permissions := capabilityRolePermissions(t, st, role.RoleID)
if _, ok := permissions["project.create"]; ok || len(permissions) != 1 || permissions["project.members.manage"].CapAllowed {
t.Fatalf("permissions after rejected enable=%+v", permissions)
}
resolved := append(enable, RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true})
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); err != nil {
t.Fatalf("explicit resolution: %v", err)
}
}
func TestCapabilityAssociationRejectsStaleVersionAndMissingRole(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "版本校验"})
if err != nil {
t.Fatal(err)
}
enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
_, err = st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion+1, enable, "stale-version")
if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrConflict) {
t.Fatalf("stale version: %v", err)
}
_, err = st.ReplaceRolePermissions("admin", "role_missing", 1, enable, "missing-role")
if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrNotFound) {
t.Fatalf("missing role: %v", err)
}
if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 {
t.Fatal("rejected write left role permissions")
}
}
func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
if linked, err := st.LinkedCapabilities("bob"); err != nil || linked.ProjectCreate || len(linked.LinkedActions) != 0 {
t.Fatalf("ungranted linked=%+v err=%v", linked, err)
}
epoch, _ := st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob-create"); err != nil {
t.Fatal(err)
}
linked, err := st.LinkedCapabilities("bob")
if err != nil {
t.Fatal(err)
}
if !linked.ProjectCreate || linked.Source != "system-grant" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") {
t.Fatalf("direct grant linked=%+v", linked)
}
if admin, err := st.LinkedCapabilities("admin"); err != nil || admin.Source != "bootstrap-admin" || len(admin.LinkedActions) != 4 {
t.Fatalf("admin linked=%+v err=%v", admin, err)
}
capped, err := st.CreateRole("admin", RoleCreate{Name: "禁止管理"})
if err != nil {
t.Fatal(err)
}
cappedReceipt, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
[]RolePermissionChange{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: false}}, "capped-role")
if err != nil {
t.Fatal(err)
}
bob, _ = st.GetAccount(bob.ID)
if _, err := st.AssignUserRole("admin", bob.ID, capped.RoleID, bob.StateVersion, "assign-capped-to-creator"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("capped role bound to project.create grant holder: %v", err)
}
if _, err := st.AssignUserRole("admin", alice.ID, capped.RoleID, alice.StateVersion, "assign-capped"); err != nil {
t.Fatal(err)
}
epoch, _ = st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-capped-create"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("system grant over role cap: %v", err)
}
if grants, _, err := st.SystemGrantsForUser(alice.ID); err != nil || len(grants) != 0 {
t.Fatalf("rejected grant persisted=%v err=%v", grants, err)
}
// A role writer cannot later close a linked cap under a grant holder either.
open, err := st.CreateRole("admin", RoleCreate{Name: "普通职位"})
if err != nil {
t.Fatal(err)
}
bob, _ = st.GetAccount(bob.ID)
if _, err := st.AssignUserRole("admin", bob.ID, open.RoleID, bob.StateVersion, "assign-open"); err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", open.RoleID, open.RoleVersion,
[]RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, "cap-under-holder"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("role cap under grant holder: %v", err)
}
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
t.Fatalf("rejected role cap persisted: %+v", permissions)
}
if role, _ := st.GetRole(capped.RoleID); role.RoleVersion != cappedReceipt.RoleVersion {
t.Fatalf("capped role changed: %+v", role)
}
}
func TestCapabilityAssociationDisablingProjectCreateKeepsLinkedRows(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "创建者"})
if err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "project.settings", Allowed: true, CapAllowed: true},
}, "enable-create")
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
t.Fatal(err)
}
disabled, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion,
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create")
if err != nil {
t.Fatal(err)
}
permissions := capabilityRolePermissions(t, st, role.RoleID)
if _, ok := permissions["project.create"]; ok {
t.Fatalf("project.create still present: %+v", permissions)
}
if settings := permissions["project.settings"]; !settings.Allowed || !settings.CapAllowed {
t.Fatalf("project.settings not kept: %+v", permissions)
}
for _, actionID := range linkedManagementActions {
if _, ok := permissions[actionID]; !ok {
t.Fatalf("%s deleted by disabling project.create: %+v", actionID, permissions)
}
}
if !strings.Contains(disabled.MigrationNote, "project.create disabled") || !strings.Contains(disabled.MigrationNote, "project.settings") {
t.Fatalf("migration note=%q", disabled.MigrationNote)
}
if linked, err := st.LinkedCapabilities("alice"); err != nil || linked.ProjectCreate {
t.Fatalf("disabled create still linked=%+v err=%v", linked, err)
}
// Removal after project.create is closed is an explicit second write.
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, disabled.RoleVersion, nil, "explicit-revoke"); err != nil {
t.Fatal(err)
}
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 {
t.Fatalf("explicit revoke kept=%+v", permissions)
}
epoch, _ := st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob"); err != nil {
t.Fatal(err)
}
epoch, _ = st.AuthorityEpoch()
revoked, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, nil, "revoke-bob")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(revoked.MigrationNote, "project.create disabled") {
t.Fatalf("system grant migration note=%q", revoked.MigrationNote)
}
}
func TestCapabilityAssociationImportRejectsLinkedConflict(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
bad := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("b", 64), Roles: []RoleAuthorityImportRole{{
RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2,
Permissions: []RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "project.permissions.manage", Allowed: false, CapAllowed: false},
},
}}}
if _, err := st.ImportRoleAuthority("admin", bad, "import-conflict"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("conflicting import staged: %v", err)
}
epoch, _ := st.AuthorityEpoch()
if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-alice"); err != nil {
t.Fatal(err)
}
cappedForHolder := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("c", 64), Roles: []RoleAuthorityImportRole{{
RoleID: "role_legacy_viewer", Name: "旧查看者", Enabled: true, RoleVersion: 1,
Permissions: []RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}},
Owners: []string{"alice"},
}}}
if _, err := st.ImportRoleAuthority("admin", cappedForHolder, "import-capped-holder"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("import capped grant holder: %v", err)
}
good := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("d", 64), Roles: []RoleAuthorityImportRole{{
RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2,
Permissions: []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
Owners: []string{"bob"},
}}}
result, err := st.ImportRoleAuthority("admin", good, "import-good")
if err != nil {
t.Fatalf("valid import after rejected attempts: %v", err)
}
if _, err := st.ActivateRoleAuthorityImport("admin", good.SourceSHA256, result.AuthorityEpoch); err != nil {
t.Fatal(err)
}
linked, err := st.LinkedCapabilities("bob")
if err != nil || linked.Source != "role" || len(linked.LinkedActions) != 4 {
t.Fatalf("imported creator linked=%+v err=%v", linked, err)
}
}
func TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically(t *testing.T) {
st := openTestStore(t)
for _, row := range []struct {
+158
View File
@@ -0,0 +1,158 @@
package extension
import (
"context"
"errors"
"net/http"
)
const CapabilityExplainSchemaVersion = 1
// Capability sources name where an allowed action comes from.
const (
CapabilitySourceRole = "role"
CapabilitySourceSystemGrant = "system-grant"
CapabilitySourceBootstrapAdmin = "bootstrap-admin"
)
// Capability scopes name where an allowed action applies.
const (
CapabilityScopeSystem = "system"
CapabilityScopeOwnProjects = "own-projects"
CapabilityScopeMemberProjects = "member-projects"
)
// CapabilitySubject identifies the Host-bound actor and the authority snapshot
// an explanation was computed from. It never names another account.
type CapabilitySubject struct {
SchemaVersion int `json:"schemaVersion"`
UserID string `json:"userId"`
Owner string `json:"owner"`
AuthorityEpoch int64 `json:"authorityEpoch"`
RoleID string `json:"roleId,omitempty"`
RoleName string `json:"roleName,omitempty"`
RoleEnabled bool `json:"roleEnabled"`
}
// CapabilityActionExplain reports one action's effective decision. Source is
// set only when Allowed; LinkedVia names the action that links it; Conflict
// marks a linked action that the enabled role caps off.
type CapabilityActionExplain struct {
ActionID string `json:"actionId"`
Scope string `json:"scope"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
Source string `json:"source,omitempty"`
LinkedVia string `json:"linkedVia,omitempty"`
Conflict bool `json:"conflict,omitempty"`
Reason string `json:"reason,omitempty"`
}
// CapabilityExplain is the creator explanation: project.create, the linked
// own-project management actions, their conflicts and the migration note.
type CapabilityExplain struct {
CapabilitySubject
ProjectCreate CapabilityActionExplain `json:"projectCreate"`
Linked []CapabilityActionExplain `json:"linked"`
Conflicts []string `json:"conflicts"`
MigrationNote string `json:"migrationNote,omitempty"`
}
// MemberBasicCollaborate explains the role defaults a member brings to the
// projects they are a member of. Bootstrap admin is not a content source.
type MemberBasicCollaborate struct {
CapabilitySubject
Actions []CapabilityActionExplain `json:"actions"`
}
// DocumentDutyQualify reports whether the role cap lets a document duty be
// assigned to the actor and whether the role grants it by default.
type DocumentDutyQualify struct {
ActionID string `json:"actionId"`
Qualified bool `json:"qualified"`
DefaultAllowed bool `json:"defaultAllowed"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
type DocumentQualify struct {
CapabilitySubject
Duties []DocumentDutyQualify `json:"duties"`
}
// CapabilityExplainProvider is optional on a trusted dispatch Host. Every
// method explains only the Host-bound actor; callers cannot name another one.
type CapabilityExplainProvider interface {
CurrentCapabilityExplain(context.Context) (CapabilityExplain, error)
CurrentMemberBasicCollaborate(context.Context) (MemberBasicCollaborate, error)
CurrentDocumentQualify(context.Context) (DocumentQualify, error)
}
// Work operations whose failures are categorized for clients.
const (
WorkOperationProjectCreate = "project.create"
WorkOperationMemberAdd = "project.member.add"
WorkOperationConversation = "conversation.work"
)
// Work failure reasons. Each maps to exactly one HTTP status.
const (
WorkFailureDuplicateName = "duplicate-name"
WorkFailurePolicyRejected = "policy-rejected"
WorkFailureMappingNotReady = "mapping-not-ready"
WorkFailureServiceUnavailable = "service-unavailable"
)
// WorkFailure is a categorized refusal a provider may return from project
// create, member add or conversation work. Its text carries only the operation
// and reason, never an account, project or name.
type WorkFailure struct {
Operation string `json:"operation"`
Reason string `json:"reason"`
}
func (f WorkFailure) Error() string { return f.Operation + ": " + f.Reason }
// StatusCode returns 0 for an unknown reason so it is never trusted as a status.
func (f WorkFailure) StatusCode() int {
switch f.Reason {
case WorkFailureDuplicateName:
return http.StatusConflict
case WorkFailurePolicyRejected:
return http.StatusForbidden
case WorkFailureMappingNotReady, WorkFailureServiceUnavailable:
return http.StatusServiceUnavailable
}
return 0
}
// CategorizeWorkFailure classifies a provider error for operation. A known
// WorkFailure keeps its reason; a coded 403/503 (and 409 outside conversation
// work) maps to its category; a deadline is service-unavailable. Anything else
// is left uncategorized so the caller keeps its existing not-found handling.
func CategorizeWorkFailure(operation string, err error) (WorkFailure, bool) {
if err == nil {
return WorkFailure{}, false
}
var failure WorkFailure
if errors.As(err, &failure) && failure.StatusCode() != 0 {
return WorkFailure{Operation: operation, Reason: failure.Reason}, true
}
var coded interface{ StatusCode() int }
if errors.As(err, &coded) {
switch coded.StatusCode() {
case http.StatusForbidden:
return WorkFailure{Operation: operation, Reason: WorkFailurePolicyRejected}, true
case http.StatusConflict:
if operation != WorkOperationConversation {
return WorkFailure{Operation: operation, Reason: WorkFailureDuplicateName}, true
}
case http.StatusServiceUnavailable:
return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true
}
}
if errors.Is(err, context.DeadlineExceeded) {
return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true
}
return WorkFailure{}, false
}
+56
View File
@@ -0,0 +1,56 @@
package extension
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"testing"
)
type codedTestError int
func (e codedTestError) Error() string { return "provider detail about other-owner" }
func (e codedTestError) StatusCode() int { return int(e) }
func TestWorkFailureStatusAndTextCarryNoIdentifiers(t *testing.T) {
for reason, code := range map[string]int{
WorkFailureDuplicateName: http.StatusConflict,
WorkFailurePolicyRejected: http.StatusForbidden,
WorkFailureMappingNotReady: http.StatusServiceUnavailable,
WorkFailureServiceUnavailable: http.StatusServiceUnavailable,
"invented": 0,
} {
failure := WorkFailure{Operation: WorkOperationMemberAdd, Reason: reason}
if failure.StatusCode() != code || failure.Error() != WorkOperationMemberAdd+": "+reason {
t.Fatalf("%s: status=%d text=%q", reason, failure.StatusCode(), failure.Error())
}
}
}
func TestCategorizeWorkFailure(t *testing.T) {
cases := []struct {
op string
err error
reason string
ok bool
}{
{WorkOperationProjectCreate, fmt.Errorf("wrap: %w", WorkFailure{Reason: WorkFailureMappingNotReady}), WorkFailureMappingNotReady, true},
{WorkOperationProjectCreate, codedTestError(409), WorkFailureDuplicateName, true},
{WorkOperationMemberAdd, codedTestError(403), WorkFailurePolicyRejected, true},
{WorkOperationConversation, codedTestError(503), WorkFailureServiceUnavailable, true},
{WorkOperationConversation, codedTestError(409), "", false},
{WorkOperationConversation, context.DeadlineExceeded, WorkFailureServiceUnavailable, true},
{WorkOperationMemberAdd, codedTestError(404), "", false},
{WorkOperationMemberAdd, WorkFailure{Reason: "invented"}, "", false},
{WorkOperationMemberAdd, errors.New("plain"), "", false},
{WorkOperationMemberAdd, nil, "", false},
}
for _, tc := range cases {
failure, ok := CategorizeWorkFailure(tc.op, tc.err)
if ok != tc.ok || failure.Reason != tc.reason || (ok && failure.Operation != tc.op) || strings.Contains(failure.Error(), "other-owner") {
t.Fatalf("%s %v: failure=%+v ok=%v", tc.op, tc.err, failure, ok)
}
}
}
+7 -1
View File
@@ -65,7 +65,13 @@ import (
// SystemActionProvider for the Backend-owned single-effective-role policy;
// resource queries carry the current authority epoch so downstream caches can
// reject stale role snapshots.
const SurfaceVersion = "2.25.0-experimental"
// 2.26.0-experimental: IAM link U1 RolePolicySnapshot.Linked carries the
// Backend-computed project.create own-project management link and its source.
// 2.27.0-experimental: IAM link U3 optional CapabilityExplainProvider
// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the
// Host-bound actor) and categorized WorkFailure for create, member add and
// conversation work.
const SurfaceVersion = "2.27.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+1
View File
@@ -49,6 +49,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error)
// - WorkTaskHost — X1:分发期建立可恢复的受信项目任务绑定
// - HumanDecisionVerifier — 本次分发是否为认证人对确切待确认卡的响应
// - RolePolicyProvider — Backend 权威职位与动作默认/上限
// - CapabilityExplainProvider — 当前分发主体的创建者/成员/文档职责能力解释
type Host interface {
// PluginID 返回注册处理器的插件标识(形如 rdm)。
PluginID() string
+12
View File
@@ -24,6 +24,18 @@ type RolePolicySnapshot struct {
RoleVersion int64 `json:"roleVersion,omitempty"`
AuthorityEpoch int64 `json:"authorityEpoch"`
Permissions map[string]RolePermission `json:"permissions"`
Linked LinkedCapabilities `json:"linked"`
}
// LinkedCapabilities is the Backend-computed project.create link for one
// account. Actions apply only to projects the account created; Source names
// where project.create comes from (bootstrap-admin, system-grant or role) and
// Conflicts lists linked actions that the account's enabled role caps off.
type LinkedCapabilities struct {
ProjectCreate bool `json:"projectCreate"`
Source string `json:"source,omitempty"`
Actions []string `json:"actions"`
Conflicts []string `json:"conflicts,omitempty"`
}
type RoleActionDecision struct {
+27
View File
@@ -26,9 +26,26 @@ AdminProvider
AdminProvider.IsAdmin
AdminZoneSpec
CLIHook
CapabilityActionExplain
CapabilityExplain
CapabilityExplainProvider
CapabilityExplainProvider.CurrentCapabilityExplain
CapabilityExplainProvider.CurrentDocumentQualify
CapabilityExplainProvider.CurrentMemberBasicCollaborate
CapabilityExplainSchemaVersion
CapabilityScopeMemberProjects
CapabilityScopeOwnProjects
CapabilityScopeSystem
CapabilitySourceBootstrapAdmin
CapabilitySourceRole
CapabilitySourceSystemGrant
CapabilitySubject
CategorizeWorkFailure
Compiled
DelegatedProjectProjectionHost
DelegatedProjectProjectionHost.ProjectProjectionForCreator
DocumentDutyQualify
DocumentQualify
ErrNotLinked
ErrProjectAuditDenied
ErrProjectAuditLinkConflict
@@ -64,7 +81,9 @@ HumanDecisionVerifier.RevokeHumanDecision
HumanDecisionVerifier.VerifyHumanDecision
InProc
IntentSpec
LinkedCapabilities
Main
MemberBasicCollaborate
MergeHostOptions
NewHostClient
NewScopedHost
@@ -239,6 +258,11 @@ WorkCompletionHost
WorkCompletionHost.CompleteForWork
WorkCompletionRequest
WorkCompletionResult
WorkFailure
WorkFailureDuplicateName
WorkFailureMappingNotReady
WorkFailurePolicyRejected
WorkFailureServiceUnavailable
WorkObservation
WorkObservationAccepted
WorkObservationDenied
@@ -246,5 +270,8 @@ WorkObservationPartial
WorkObservationPending
WorkObservationResult
WorkObservationState
WorkOperationConversation
WorkOperationMemberAdd
WorkOperationProjectCreate
WorkTaskHost
WorkTaskHost.TaskCreateForWork