diff --git a/docs-site/public/openapi/workspace-v1.yaml b/docs-site/public/openapi/workspace-v1.yaml
index cda193f..8e15ba3 100644
--- a/docs-site/public/openapi/workspace-v1.yaml
+++ b/docs-site/public/openapi/workspace-v1.yaml
@@ -1848,7 +1848,9 @@ components:
items:
$ref: '#/components/schemas/workspace.v1.UsersetTree'
title: children
- description: 'For UNION / INTERSECTION / EXCLUSION nodes: the child subtrees.'
+ description: |-
+ For UNION / INTERSECTION nodes: the child subtrees. Empty for EXCLUSION
+ (which uses `include` / `exclude` instead).
expanded:
title: expanded
description: The userset this node expands.
@@ -1857,6 +1859,16 @@ components:
type: boolean
title: wildcard
description: 'For LEAF nodes: true if the public wildcard subject is present.'
+ include:
+ title: include
+ description: |-
+ For EXCLUSION nodes only: the include leg (the base set) and the exclude
+ leg (subjects removed from it). The effective set is `include` minus
+ `exclude`. Unset for every other node type.
+ $ref: '#/components/schemas/workspace.v1.UsersetTree'
+ exclude:
+ title: exclude
+ $ref: '#/components/schemas/workspace.v1.UsersetTree'
title: UsersetTree
additionalProperties: false
workspace.v1.Workspace:
diff --git a/docs-site/public/proto/index.html b/docs-site/public/proto/index.html
index 2891257..ca881f1 100644
--- a/docs-site/public/proto/index.html
+++ b/docs-site/public/proto/index.html
@@ -4352,7 +4352,8 @@
UsersetTree
children |
UsersetTree |
repeated |
- For UNION / INTERSECTION / EXCLUSION nodes: the child subtrees. |
+ For UNION / INTERSECTION nodes: the child subtrees. Empty for EXCLUSION
+(which uses `include` / `exclude` instead). |
@@ -4369,6 +4370,22 @@ UsersetTree
For LEAF nodes: true if the public wildcard subject is present. |
+
+ | include |
+ UsersetTree |
+ |
+ For EXCLUSION nodes only: the include leg (the base set) and the exclude
+leg (subjects removed from it). The effective set is `include` minus
+`exclude`. Unset for every other node type. |
+
+
+
+ | exclude |
+ UsersetTree |
+ |
+ |
+
+
@@ -4780,7 +4797,9 @@ UsersetTree.NodeType
| NODE_TYPE_EXCLUSION |
4 |
- EXCLUSION nodes carry exactly two children: [include, exclude]. |
+ EXCLUSION encodes its operands EXPLICITLY in `include` / `exclude`, NOT
+in `children`: the effective set is `include` minus `exclude`. `children`
+is empty for EXCLUSION nodes. |
diff --git a/docs-site/public/proto/workspace-v1.proto b/docs-site/public/proto/workspace-v1.proto
index 075e218..6364d00 100644
--- a/docs-site/public/proto/workspace-v1.proto
+++ b/docs-site/public/proto/workspace-v1.proto
@@ -651,19 +651,27 @@ message UsersetTree {
NODE_TYPE_UNION = 1;
NODE_TYPE_LEAF = 2;
NODE_TYPE_INTERSECTION = 3;
- // EXCLUSION nodes carry exactly two children: [include, exclude].
+ // EXCLUSION encodes its operands EXPLICITLY in `include` / `exclude`, NOT
+ // in `children`: the effective set is `include` minus `exclude`. `children`
+ // is empty for EXCLUSION nodes.
NODE_TYPE_EXCLUSION = 4;
}
NodeType type = 1;
// For LEAF nodes: the concrete subjects and usersets at this node.
repeated string user_ids = 2;
repeated SubjectSet sets = 3;
- // For UNION / INTERSECTION / EXCLUSION nodes: the child subtrees.
+ // For UNION / INTERSECTION nodes: the child subtrees. Empty for EXCLUSION
+ // (which uses `include` / `exclude` instead).
repeated UsersetTree children = 4;
// The userset this node expands.
SubjectSet expanded = 5;
// For LEAF nodes: true if the public wildcard subject is present.
bool wildcard = 6;
+ // For EXCLUSION nodes only: the include leg (the base set) and the exclude
+ // leg (subjects removed from it). The effective set is `include` minus
+ // `exclude`. Unset for every other node type.
+ UsersetTree include = 7;
+ UsersetTree exclude = 8;
}
message ExpandResponse { UsersetTree tree = 1; }
diff --git a/docs/authorization-model.md b/docs/authorization-model.md
index 12d6665..d9b5986 100644
--- a/docs/authorization-model.md
+++ b/docs/authorization-model.md
@@ -345,9 +345,13 @@ Traversal must carry a visited-set / depth bound so cyclic group nesting cannot
loop (see ADR-0003).
`Expand(namespace, object_id, relation)` is the set-valued sibling: instead of
-testing one user, it returns the effective **`UsersetTree`** — `UNION` nodes
-with child subtrees, and `LEAF` nodes carrying concrete `user_ids` and nested
-`sets`. Use it to answer "who has access?" and for audit.
+testing one user, it returns the effective **`UsersetTree`** — `UNION` /
+`INTERSECTION` nodes with child subtrees (in `children`), and `LEAF` nodes
+carrying concrete `user_ids` and nested `sets`. An `EXCLUSION` node encodes its
+operands **explicitly** in the dedicated `include` and `exclude` fields (the
+effective set is `include` minus `exclude`); it does **not** use `children` and
+relies on no positional convention. Use it to answer "who has access?" and for
+audit.
`ReadRelationTuples` is **not** a permission check — it returns raw stored
tuples matching an exact filter, with no rewrite evaluation. Use `Check` for
diff --git a/gen/go/workspace/v1/workspace.pb.go b/gen/go/workspace/v1/workspace.pb.go
index 61f8f93..29b514a 100644
--- a/gen/go/workspace/v1/workspace.pb.go
+++ b/gen/go/workspace/v1/workspace.pb.go
@@ -421,7 +421,9 @@ const (
UsersetTree_NODE_TYPE_UNION UsersetTree_NodeType = 1
UsersetTree_NODE_TYPE_LEAF UsersetTree_NodeType = 2
UsersetTree_NODE_TYPE_INTERSECTION UsersetTree_NodeType = 3
- // EXCLUSION nodes carry exactly two children: [include, exclude].
+ // EXCLUSION encodes its operands EXPLICITLY in `include` / `exclude`, NOT
+ // in `children`: the effective set is `include` minus `exclude`. `children`
+ // is empty for EXCLUSION nodes.
UsersetTree_NODE_TYPE_EXCLUSION UsersetTree_NodeType = 4
)
@@ -5060,12 +5062,18 @@ type UsersetTree struct {
// For LEAF nodes: the concrete subjects and usersets at this node.
UserIds []string `protobuf:"bytes,2,rep,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"`
Sets []*SubjectSet `protobuf:"bytes,3,rep,name=sets,proto3" json:"sets,omitempty"`
- // For UNION / INTERSECTION / EXCLUSION nodes: the child subtrees.
+ // For UNION / INTERSECTION nodes: the child subtrees. Empty for EXCLUSION
+ // (which uses `include` / `exclude` instead).
Children []*UsersetTree `protobuf:"bytes,4,rep,name=children,proto3" json:"children,omitempty"`
// The userset this node expands.
Expanded *SubjectSet `protobuf:"bytes,5,opt,name=expanded,proto3" json:"expanded,omitempty"`
// For LEAF nodes: true if the public wildcard subject is present.
- Wildcard bool `protobuf:"varint,6,opt,name=wildcard,proto3" json:"wildcard,omitempty"`
+ Wildcard bool `protobuf:"varint,6,opt,name=wildcard,proto3" json:"wildcard,omitempty"`
+ // For EXCLUSION nodes only: the include leg (the base set) and the exclude
+ // leg (subjects removed from it). The effective set is `include` minus
+ // `exclude`. Unset for every other node type.
+ Include *UsersetTree `protobuf:"bytes,7,opt,name=include,proto3" json:"include,omitempty"`
+ Exclude *UsersetTree `protobuf:"bytes,8,opt,name=exclude,proto3" json:"exclude,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -5142,6 +5150,20 @@ func (x *UsersetTree) GetWildcard() bool {
return false
}
+func (x *UsersetTree) GetInclude() *UsersetTree {
+ if x != nil {
+ return x.Include
+ }
+ return nil
+}
+
+func (x *UsersetTree) GetExclude() *UsersetTree {
+ if x != nil {
+ return x.Exclude
+ }
+ return nil
+}
+
type ExpandResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Tree *UsersetTree `protobuf:"bytes,1,opt,name=tree,proto3" json:"tree,omitempty"`
@@ -7183,14 +7205,16 @@ const file_workspace_v1_workspace_proto_rawDesc = "" +
"\n" +
"project_id\x18\x04 \x01(\tR\tprojectId\x12\x1b\n" +
"\ttenant_id\x18\x05 \x01(\tR\btenantId\x12;\n" +
- "\x1aat_least_consistency_token\x18\x06 \x01(\tR\x17atLeastConsistencyToken\"\x9d\x03\n" +
+ "\x1aat_least_consistency_token\x18\x06 \x01(\tR\x17atLeastConsistencyToken\"\x87\x04\n" +
"\vUsersetTree\x126\n" +
"\x04type\x18\x01 \x01(\x0e2\".workspace.v1.UsersetTree.NodeTypeR\x04type\x12\x19\n" +
"\buser_ids\x18\x02 \x03(\tR\auserIds\x12,\n" +
"\x04sets\x18\x03 \x03(\v2\x18.workspace.v1.SubjectSetR\x04sets\x125\n" +
"\bchildren\x18\x04 \x03(\v2\x19.workspace.v1.UsersetTreeR\bchildren\x124\n" +
"\bexpanded\x18\x05 \x01(\v2\x18.workspace.v1.SubjectSetR\bexpanded\x12\x1a\n" +
- "\bwildcard\x18\x06 \x01(\bR\bwildcard\"\x83\x01\n" +
+ "\bwildcard\x18\x06 \x01(\bR\bwildcard\x123\n" +
+ "\ainclude\x18\a \x01(\v2\x19.workspace.v1.UsersetTreeR\ainclude\x123\n" +
+ "\aexclude\x18\b \x01(\v2\x19.workspace.v1.UsersetTreeR\aexclude\"\x83\x01\n" +
"\bNodeType\x12\x19\n" +
"\x15NODE_TYPE_UNSPECIFIED\x10\x00\x12\x13\n" +
"\x0fNODE_TYPE_UNION\x10\x01\x12\x12\n" +
@@ -7593,108 +7617,110 @@ var file_workspace_v1_workspace_proto_depIdxs = []int32{
64, // 56: workspace.v1.UsersetTree.sets:type_name -> workspace.v1.SubjectSet
79, // 57: workspace.v1.UsersetTree.children:type_name -> workspace.v1.UsersetTree
64, // 58: workspace.v1.UsersetTree.expanded:type_name -> workspace.v1.SubjectSet
- 79, // 59: workspace.v1.ExpandResponse.tree:type_name -> workspace.v1.UsersetTree
- 86, // 60: workspace.v1.ExportSubjectGrantsResponse.grants:type_name -> workspace.v1.SubjectGrant
- 5, // 61: workspace.v1.Project.status:type_name -> workspace.v1.ProjectStatus
- 109, // 62: workspace.v1.Project.created_at:type_name -> google.protobuf.Timestamp
- 109, // 63: workspace.v1.Project.updated_at:type_name -> google.protobuf.Timestamp
- 88, // 64: workspace.v1.CreateProjectResponse.project:type_name -> workspace.v1.Project
- 88, // 65: workspace.v1.GetProjectResponse.project:type_name -> workspace.v1.Project
- 5, // 66: workspace.v1.UpdateProjectRequest.status:type_name -> workspace.v1.ProjectStatus
- 88, // 67: workspace.v1.UpdateProjectResponse.project:type_name -> workspace.v1.Project
- 88, // 68: workspace.v1.ListProjectsResponse.projects:type_name -> workspace.v1.Project
- 109, // 69: workspace.v1.SeatAssignment.assigned_at:type_name -> google.protobuf.Timestamp
- 97, // 70: workspace.v1.SetSeatLimitResponse.limit:type_name -> workspace.v1.SeatLimit
- 98, // 71: workspace.v1.ListSeatsResponse.seats:type_name -> workspace.v1.SeatAssignment
- 11, // 72: workspace.v1.WorkspaceService.CreateWorkspace:input_type -> workspace.v1.CreateWorkspaceRequest
- 13, // 73: workspace.v1.WorkspaceService.GetWorkspace:input_type -> workspace.v1.GetWorkspaceRequest
- 15, // 74: workspace.v1.WorkspaceService.ListWorkspaces:input_type -> workspace.v1.ListWorkspacesRequest
- 17, // 75: workspace.v1.WorkspaceService.UpdateWorkspace:input_type -> workspace.v1.UpdateWorkspaceRequest
- 19, // 76: workspace.v1.WorkspaceService.TransferOwnership:input_type -> workspace.v1.TransferOwnershipRequest
- 21, // 77: workspace.v1.WorkspaceService.DeleteWorkspace:input_type -> workspace.v1.DeleteWorkspaceRequest
- 23, // 78: workspace.v1.WorkspaceService.AddMember:input_type -> workspace.v1.AddMemberRequest
- 25, // 79: workspace.v1.WorkspaceService.UpdateMemberRole:input_type -> workspace.v1.UpdateMemberRoleRequest
- 27, // 80: workspace.v1.WorkspaceService.RemoveMember:input_type -> workspace.v1.RemoveMemberRequest
- 29, // 81: workspace.v1.WorkspaceService.SuspendMember:input_type -> workspace.v1.SuspendMemberRequest
- 31, // 82: workspace.v1.WorkspaceService.ReinstateMember:input_type -> workspace.v1.ReinstateMemberRequest
- 33, // 83: workspace.v1.WorkspaceService.ListMembers:input_type -> workspace.v1.ListMembersRequest
- 35, // 84: workspace.v1.WorkspaceService.CreateInvitation:input_type -> workspace.v1.CreateInvitationRequest
- 37, // 85: workspace.v1.WorkspaceService.AcceptInvitation:input_type -> workspace.v1.AcceptInvitationRequest
- 39, // 86: workspace.v1.WorkspaceService.ListInvitations:input_type -> workspace.v1.ListInvitationsRequest
- 41, // 87: workspace.v1.WorkspaceService.RevokeInvitation:input_type -> workspace.v1.RevokeInvitationRequest
- 45, // 88: workspace.v1.GroupService.CreateGroup:input_type -> workspace.v1.CreateGroupRequest
- 47, // 89: workspace.v1.GroupService.GetGroup:input_type -> workspace.v1.GetGroupRequest
- 49, // 90: workspace.v1.GroupService.ListGroups:input_type -> workspace.v1.ListGroupsRequest
- 51, // 91: workspace.v1.GroupService.DeleteGroup:input_type -> workspace.v1.DeleteGroupRequest
- 53, // 92: workspace.v1.GroupService.AddGroupMember:input_type -> workspace.v1.AddGroupMemberRequest
- 55, // 93: workspace.v1.GroupService.RemoveGroupMember:input_type -> workspace.v1.RemoveGroupMemberRequest
- 57, // 94: workspace.v1.GroupService.ListGroupMembers:input_type -> workspace.v1.ListGroupMembersRequest
- 60, // 95: workspace.v1.GroupService.SetEnrollmentState:input_type -> workspace.v1.SetEnrollmentStateRequest
- 62, // 96: workspace.v1.GroupService.ListEnrollments:input_type -> workspace.v1.ListEnrollmentsRequest
- 68, // 97: workspace.v1.AuthzService.WriteRelationTuples:input_type -> workspace.v1.WriteRelationTuplesRequest
- 70, // 98: workspace.v1.AuthzService.ReadRelationTuples:input_type -> workspace.v1.ReadRelationTuplesRequest
- 72, // 99: workspace.v1.AuthzService.Check:input_type -> workspace.v1.CheckRequest
- 75, // 100: workspace.v1.AuthzService.BatchCheck:input_type -> workspace.v1.BatchCheckRequest
- 78, // 101: workspace.v1.AuthzService.Expand:input_type -> workspace.v1.ExpandRequest
- 81, // 102: workspace.v1.AuthzService.ListObjects:input_type -> workspace.v1.ListObjectsRequest
- 83, // 103: workspace.v1.AuthzService.DeprovisionUser:input_type -> workspace.v1.DeprovisionUserRequest
- 85, // 104: workspace.v1.AuthzService.ExportSubjectGrants:input_type -> workspace.v1.ExportSubjectGrantsRequest
- 89, // 105: workspace.v1.AdminService.CreateProject:input_type -> workspace.v1.CreateProjectRequest
- 91, // 106: workspace.v1.AdminService.GetProject:input_type -> workspace.v1.GetProjectRequest
- 93, // 107: workspace.v1.AdminService.UpdateProject:input_type -> workspace.v1.UpdateProjectRequest
- 95, // 108: workspace.v1.AdminService.ListProjects:input_type -> workspace.v1.ListProjectsRequest
- 99, // 109: workspace.v1.SeatService.SetSeatLimit:input_type -> workspace.v1.SetSeatLimitRequest
- 101, // 110: workspace.v1.SeatService.GetSeatUsage:input_type -> workspace.v1.GetSeatUsageRequest
- 103, // 111: workspace.v1.SeatService.AssignSeat:input_type -> workspace.v1.AssignSeatRequest
- 105, // 112: workspace.v1.SeatService.RevokeSeat:input_type -> workspace.v1.RevokeSeatRequest
- 107, // 113: workspace.v1.SeatService.ListSeats:input_type -> workspace.v1.ListSeatsRequest
- 12, // 114: workspace.v1.WorkspaceService.CreateWorkspace:output_type -> workspace.v1.CreateWorkspaceResponse
- 14, // 115: workspace.v1.WorkspaceService.GetWorkspace:output_type -> workspace.v1.GetWorkspaceResponse
- 16, // 116: workspace.v1.WorkspaceService.ListWorkspaces:output_type -> workspace.v1.ListWorkspacesResponse
- 18, // 117: workspace.v1.WorkspaceService.UpdateWorkspace:output_type -> workspace.v1.UpdateWorkspaceResponse
- 20, // 118: workspace.v1.WorkspaceService.TransferOwnership:output_type -> workspace.v1.TransferOwnershipResponse
- 22, // 119: workspace.v1.WorkspaceService.DeleteWorkspace:output_type -> workspace.v1.DeleteWorkspaceResponse
- 24, // 120: workspace.v1.WorkspaceService.AddMember:output_type -> workspace.v1.AddMemberResponse
- 26, // 121: workspace.v1.WorkspaceService.UpdateMemberRole:output_type -> workspace.v1.UpdateMemberRoleResponse
- 28, // 122: workspace.v1.WorkspaceService.RemoveMember:output_type -> workspace.v1.RemoveMemberResponse
- 30, // 123: workspace.v1.WorkspaceService.SuspendMember:output_type -> workspace.v1.SuspendMemberResponse
- 32, // 124: workspace.v1.WorkspaceService.ReinstateMember:output_type -> workspace.v1.ReinstateMemberResponse
- 34, // 125: workspace.v1.WorkspaceService.ListMembers:output_type -> workspace.v1.ListMembersResponse
- 36, // 126: workspace.v1.WorkspaceService.CreateInvitation:output_type -> workspace.v1.CreateInvitationResponse
- 38, // 127: workspace.v1.WorkspaceService.AcceptInvitation:output_type -> workspace.v1.AcceptInvitationResponse
- 40, // 128: workspace.v1.WorkspaceService.ListInvitations:output_type -> workspace.v1.ListInvitationsResponse
- 42, // 129: workspace.v1.WorkspaceService.RevokeInvitation:output_type -> workspace.v1.RevokeInvitationResponse
- 46, // 130: workspace.v1.GroupService.CreateGroup:output_type -> workspace.v1.CreateGroupResponse
- 48, // 131: workspace.v1.GroupService.GetGroup:output_type -> workspace.v1.GetGroupResponse
- 50, // 132: workspace.v1.GroupService.ListGroups:output_type -> workspace.v1.ListGroupsResponse
- 52, // 133: workspace.v1.GroupService.DeleteGroup:output_type -> workspace.v1.DeleteGroupResponse
- 54, // 134: workspace.v1.GroupService.AddGroupMember:output_type -> workspace.v1.AddGroupMemberResponse
- 56, // 135: workspace.v1.GroupService.RemoveGroupMember:output_type -> workspace.v1.RemoveGroupMemberResponse
- 58, // 136: workspace.v1.GroupService.ListGroupMembers:output_type -> workspace.v1.ListGroupMembersResponse
- 61, // 137: workspace.v1.GroupService.SetEnrollmentState:output_type -> workspace.v1.SetEnrollmentStateResponse
- 63, // 138: workspace.v1.GroupService.ListEnrollments:output_type -> workspace.v1.ListEnrollmentsResponse
- 69, // 139: workspace.v1.AuthzService.WriteRelationTuples:output_type -> workspace.v1.WriteRelationTuplesResponse
- 71, // 140: workspace.v1.AuthzService.ReadRelationTuples:output_type -> workspace.v1.ReadRelationTuplesResponse
- 73, // 141: workspace.v1.AuthzService.Check:output_type -> workspace.v1.CheckResponse
- 77, // 142: workspace.v1.AuthzService.BatchCheck:output_type -> workspace.v1.BatchCheckResponse
- 80, // 143: workspace.v1.AuthzService.Expand:output_type -> workspace.v1.ExpandResponse
- 82, // 144: workspace.v1.AuthzService.ListObjects:output_type -> workspace.v1.ListObjectsResponse
- 84, // 145: workspace.v1.AuthzService.DeprovisionUser:output_type -> workspace.v1.DeprovisionUserResponse
- 87, // 146: workspace.v1.AuthzService.ExportSubjectGrants:output_type -> workspace.v1.ExportSubjectGrantsResponse
- 90, // 147: workspace.v1.AdminService.CreateProject:output_type -> workspace.v1.CreateProjectResponse
- 92, // 148: workspace.v1.AdminService.GetProject:output_type -> workspace.v1.GetProjectResponse
- 94, // 149: workspace.v1.AdminService.UpdateProject:output_type -> workspace.v1.UpdateProjectResponse
- 96, // 150: workspace.v1.AdminService.ListProjects:output_type -> workspace.v1.ListProjectsResponse
- 100, // 151: workspace.v1.SeatService.SetSeatLimit:output_type -> workspace.v1.SetSeatLimitResponse
- 102, // 152: workspace.v1.SeatService.GetSeatUsage:output_type -> workspace.v1.GetSeatUsageResponse
- 104, // 153: workspace.v1.SeatService.AssignSeat:output_type -> workspace.v1.AssignSeatResponse
- 106, // 154: workspace.v1.SeatService.RevokeSeat:output_type -> workspace.v1.RevokeSeatResponse
- 108, // 155: workspace.v1.SeatService.ListSeats:output_type -> workspace.v1.ListSeatsResponse
- 114, // [114:156] is the sub-list for method output_type
- 72, // [72:114] is the sub-list for method input_type
- 72, // [72:72] is the sub-list for extension type_name
- 72, // [72:72] is the sub-list for extension extendee
- 0, // [0:72] is the sub-list for field type_name
+ 79, // 59: workspace.v1.UsersetTree.include:type_name -> workspace.v1.UsersetTree
+ 79, // 60: workspace.v1.UsersetTree.exclude:type_name -> workspace.v1.UsersetTree
+ 79, // 61: workspace.v1.ExpandResponse.tree:type_name -> workspace.v1.UsersetTree
+ 86, // 62: workspace.v1.ExportSubjectGrantsResponse.grants:type_name -> workspace.v1.SubjectGrant
+ 5, // 63: workspace.v1.Project.status:type_name -> workspace.v1.ProjectStatus
+ 109, // 64: workspace.v1.Project.created_at:type_name -> google.protobuf.Timestamp
+ 109, // 65: workspace.v1.Project.updated_at:type_name -> google.protobuf.Timestamp
+ 88, // 66: workspace.v1.CreateProjectResponse.project:type_name -> workspace.v1.Project
+ 88, // 67: workspace.v1.GetProjectResponse.project:type_name -> workspace.v1.Project
+ 5, // 68: workspace.v1.UpdateProjectRequest.status:type_name -> workspace.v1.ProjectStatus
+ 88, // 69: workspace.v1.UpdateProjectResponse.project:type_name -> workspace.v1.Project
+ 88, // 70: workspace.v1.ListProjectsResponse.projects:type_name -> workspace.v1.Project
+ 109, // 71: workspace.v1.SeatAssignment.assigned_at:type_name -> google.protobuf.Timestamp
+ 97, // 72: workspace.v1.SetSeatLimitResponse.limit:type_name -> workspace.v1.SeatLimit
+ 98, // 73: workspace.v1.ListSeatsResponse.seats:type_name -> workspace.v1.SeatAssignment
+ 11, // 74: workspace.v1.WorkspaceService.CreateWorkspace:input_type -> workspace.v1.CreateWorkspaceRequest
+ 13, // 75: workspace.v1.WorkspaceService.GetWorkspace:input_type -> workspace.v1.GetWorkspaceRequest
+ 15, // 76: workspace.v1.WorkspaceService.ListWorkspaces:input_type -> workspace.v1.ListWorkspacesRequest
+ 17, // 77: workspace.v1.WorkspaceService.UpdateWorkspace:input_type -> workspace.v1.UpdateWorkspaceRequest
+ 19, // 78: workspace.v1.WorkspaceService.TransferOwnership:input_type -> workspace.v1.TransferOwnershipRequest
+ 21, // 79: workspace.v1.WorkspaceService.DeleteWorkspace:input_type -> workspace.v1.DeleteWorkspaceRequest
+ 23, // 80: workspace.v1.WorkspaceService.AddMember:input_type -> workspace.v1.AddMemberRequest
+ 25, // 81: workspace.v1.WorkspaceService.UpdateMemberRole:input_type -> workspace.v1.UpdateMemberRoleRequest
+ 27, // 82: workspace.v1.WorkspaceService.RemoveMember:input_type -> workspace.v1.RemoveMemberRequest
+ 29, // 83: workspace.v1.WorkspaceService.SuspendMember:input_type -> workspace.v1.SuspendMemberRequest
+ 31, // 84: workspace.v1.WorkspaceService.ReinstateMember:input_type -> workspace.v1.ReinstateMemberRequest
+ 33, // 85: workspace.v1.WorkspaceService.ListMembers:input_type -> workspace.v1.ListMembersRequest
+ 35, // 86: workspace.v1.WorkspaceService.CreateInvitation:input_type -> workspace.v1.CreateInvitationRequest
+ 37, // 87: workspace.v1.WorkspaceService.AcceptInvitation:input_type -> workspace.v1.AcceptInvitationRequest
+ 39, // 88: workspace.v1.WorkspaceService.ListInvitations:input_type -> workspace.v1.ListInvitationsRequest
+ 41, // 89: workspace.v1.WorkspaceService.RevokeInvitation:input_type -> workspace.v1.RevokeInvitationRequest
+ 45, // 90: workspace.v1.GroupService.CreateGroup:input_type -> workspace.v1.CreateGroupRequest
+ 47, // 91: workspace.v1.GroupService.GetGroup:input_type -> workspace.v1.GetGroupRequest
+ 49, // 92: workspace.v1.GroupService.ListGroups:input_type -> workspace.v1.ListGroupsRequest
+ 51, // 93: workspace.v1.GroupService.DeleteGroup:input_type -> workspace.v1.DeleteGroupRequest
+ 53, // 94: workspace.v1.GroupService.AddGroupMember:input_type -> workspace.v1.AddGroupMemberRequest
+ 55, // 95: workspace.v1.GroupService.RemoveGroupMember:input_type -> workspace.v1.RemoveGroupMemberRequest
+ 57, // 96: workspace.v1.GroupService.ListGroupMembers:input_type -> workspace.v1.ListGroupMembersRequest
+ 60, // 97: workspace.v1.GroupService.SetEnrollmentState:input_type -> workspace.v1.SetEnrollmentStateRequest
+ 62, // 98: workspace.v1.GroupService.ListEnrollments:input_type -> workspace.v1.ListEnrollmentsRequest
+ 68, // 99: workspace.v1.AuthzService.WriteRelationTuples:input_type -> workspace.v1.WriteRelationTuplesRequest
+ 70, // 100: workspace.v1.AuthzService.ReadRelationTuples:input_type -> workspace.v1.ReadRelationTuplesRequest
+ 72, // 101: workspace.v1.AuthzService.Check:input_type -> workspace.v1.CheckRequest
+ 75, // 102: workspace.v1.AuthzService.BatchCheck:input_type -> workspace.v1.BatchCheckRequest
+ 78, // 103: workspace.v1.AuthzService.Expand:input_type -> workspace.v1.ExpandRequest
+ 81, // 104: workspace.v1.AuthzService.ListObjects:input_type -> workspace.v1.ListObjectsRequest
+ 83, // 105: workspace.v1.AuthzService.DeprovisionUser:input_type -> workspace.v1.DeprovisionUserRequest
+ 85, // 106: workspace.v1.AuthzService.ExportSubjectGrants:input_type -> workspace.v1.ExportSubjectGrantsRequest
+ 89, // 107: workspace.v1.AdminService.CreateProject:input_type -> workspace.v1.CreateProjectRequest
+ 91, // 108: workspace.v1.AdminService.GetProject:input_type -> workspace.v1.GetProjectRequest
+ 93, // 109: workspace.v1.AdminService.UpdateProject:input_type -> workspace.v1.UpdateProjectRequest
+ 95, // 110: workspace.v1.AdminService.ListProjects:input_type -> workspace.v1.ListProjectsRequest
+ 99, // 111: workspace.v1.SeatService.SetSeatLimit:input_type -> workspace.v1.SetSeatLimitRequest
+ 101, // 112: workspace.v1.SeatService.GetSeatUsage:input_type -> workspace.v1.GetSeatUsageRequest
+ 103, // 113: workspace.v1.SeatService.AssignSeat:input_type -> workspace.v1.AssignSeatRequest
+ 105, // 114: workspace.v1.SeatService.RevokeSeat:input_type -> workspace.v1.RevokeSeatRequest
+ 107, // 115: workspace.v1.SeatService.ListSeats:input_type -> workspace.v1.ListSeatsRequest
+ 12, // 116: workspace.v1.WorkspaceService.CreateWorkspace:output_type -> workspace.v1.CreateWorkspaceResponse
+ 14, // 117: workspace.v1.WorkspaceService.GetWorkspace:output_type -> workspace.v1.GetWorkspaceResponse
+ 16, // 118: workspace.v1.WorkspaceService.ListWorkspaces:output_type -> workspace.v1.ListWorkspacesResponse
+ 18, // 119: workspace.v1.WorkspaceService.UpdateWorkspace:output_type -> workspace.v1.UpdateWorkspaceResponse
+ 20, // 120: workspace.v1.WorkspaceService.TransferOwnership:output_type -> workspace.v1.TransferOwnershipResponse
+ 22, // 121: workspace.v1.WorkspaceService.DeleteWorkspace:output_type -> workspace.v1.DeleteWorkspaceResponse
+ 24, // 122: workspace.v1.WorkspaceService.AddMember:output_type -> workspace.v1.AddMemberResponse
+ 26, // 123: workspace.v1.WorkspaceService.UpdateMemberRole:output_type -> workspace.v1.UpdateMemberRoleResponse
+ 28, // 124: workspace.v1.WorkspaceService.RemoveMember:output_type -> workspace.v1.RemoveMemberResponse
+ 30, // 125: workspace.v1.WorkspaceService.SuspendMember:output_type -> workspace.v1.SuspendMemberResponse
+ 32, // 126: workspace.v1.WorkspaceService.ReinstateMember:output_type -> workspace.v1.ReinstateMemberResponse
+ 34, // 127: workspace.v1.WorkspaceService.ListMembers:output_type -> workspace.v1.ListMembersResponse
+ 36, // 128: workspace.v1.WorkspaceService.CreateInvitation:output_type -> workspace.v1.CreateInvitationResponse
+ 38, // 129: workspace.v1.WorkspaceService.AcceptInvitation:output_type -> workspace.v1.AcceptInvitationResponse
+ 40, // 130: workspace.v1.WorkspaceService.ListInvitations:output_type -> workspace.v1.ListInvitationsResponse
+ 42, // 131: workspace.v1.WorkspaceService.RevokeInvitation:output_type -> workspace.v1.RevokeInvitationResponse
+ 46, // 132: workspace.v1.GroupService.CreateGroup:output_type -> workspace.v1.CreateGroupResponse
+ 48, // 133: workspace.v1.GroupService.GetGroup:output_type -> workspace.v1.GetGroupResponse
+ 50, // 134: workspace.v1.GroupService.ListGroups:output_type -> workspace.v1.ListGroupsResponse
+ 52, // 135: workspace.v1.GroupService.DeleteGroup:output_type -> workspace.v1.DeleteGroupResponse
+ 54, // 136: workspace.v1.GroupService.AddGroupMember:output_type -> workspace.v1.AddGroupMemberResponse
+ 56, // 137: workspace.v1.GroupService.RemoveGroupMember:output_type -> workspace.v1.RemoveGroupMemberResponse
+ 58, // 138: workspace.v1.GroupService.ListGroupMembers:output_type -> workspace.v1.ListGroupMembersResponse
+ 61, // 139: workspace.v1.GroupService.SetEnrollmentState:output_type -> workspace.v1.SetEnrollmentStateResponse
+ 63, // 140: workspace.v1.GroupService.ListEnrollments:output_type -> workspace.v1.ListEnrollmentsResponse
+ 69, // 141: workspace.v1.AuthzService.WriteRelationTuples:output_type -> workspace.v1.WriteRelationTuplesResponse
+ 71, // 142: workspace.v1.AuthzService.ReadRelationTuples:output_type -> workspace.v1.ReadRelationTuplesResponse
+ 73, // 143: workspace.v1.AuthzService.Check:output_type -> workspace.v1.CheckResponse
+ 77, // 144: workspace.v1.AuthzService.BatchCheck:output_type -> workspace.v1.BatchCheckResponse
+ 80, // 145: workspace.v1.AuthzService.Expand:output_type -> workspace.v1.ExpandResponse
+ 82, // 146: workspace.v1.AuthzService.ListObjects:output_type -> workspace.v1.ListObjectsResponse
+ 84, // 147: workspace.v1.AuthzService.DeprovisionUser:output_type -> workspace.v1.DeprovisionUserResponse
+ 87, // 148: workspace.v1.AuthzService.ExportSubjectGrants:output_type -> workspace.v1.ExportSubjectGrantsResponse
+ 90, // 149: workspace.v1.AdminService.CreateProject:output_type -> workspace.v1.CreateProjectResponse
+ 92, // 150: workspace.v1.AdminService.GetProject:output_type -> workspace.v1.GetProjectResponse
+ 94, // 151: workspace.v1.AdminService.UpdateProject:output_type -> workspace.v1.UpdateProjectResponse
+ 96, // 152: workspace.v1.AdminService.ListProjects:output_type -> workspace.v1.ListProjectsResponse
+ 100, // 153: workspace.v1.SeatService.SetSeatLimit:output_type -> workspace.v1.SetSeatLimitResponse
+ 102, // 154: workspace.v1.SeatService.GetSeatUsage:output_type -> workspace.v1.GetSeatUsageResponse
+ 104, // 155: workspace.v1.SeatService.AssignSeat:output_type -> workspace.v1.AssignSeatResponse
+ 106, // 156: workspace.v1.SeatService.RevokeSeat:output_type -> workspace.v1.RevokeSeatResponse
+ 108, // 157: workspace.v1.SeatService.ListSeats:output_type -> workspace.v1.ListSeatsResponse
+ 116, // [116:158] is the sub-list for method output_type
+ 74, // [74:116] is the sub-list for method input_type
+ 74, // [74:74] is the sub-list for extension type_name
+ 74, // [74:74] is the sub-list for extension extendee
+ 0, // [0:74] is the sub-list for field type_name
}
func init() { file_workspace_v1_workspace_proto_init() }
diff --git a/gen/openapi/workspace/v1/workspace.openapi.yaml b/gen/openapi/workspace/v1/workspace.openapi.yaml
index cda193f..8e15ba3 100644
--- a/gen/openapi/workspace/v1/workspace.openapi.yaml
+++ b/gen/openapi/workspace/v1/workspace.openapi.yaml
@@ -1848,7 +1848,9 @@ components:
items:
$ref: '#/components/schemas/workspace.v1.UsersetTree'
title: children
- description: 'For UNION / INTERSECTION / EXCLUSION nodes: the child subtrees.'
+ description: |-
+ For UNION / INTERSECTION nodes: the child subtrees. Empty for EXCLUSION
+ (which uses `include` / `exclude` instead).
expanded:
title: expanded
description: The userset this node expands.
@@ -1857,6 +1859,16 @@ components:
type: boolean
title: wildcard
description: 'For LEAF nodes: true if the public wildcard subject is present.'
+ include:
+ title: include
+ description: |-
+ For EXCLUSION nodes only: the include leg (the base set) and the exclude
+ leg (subjects removed from it). The effective set is `include` minus
+ `exclude`. Unset for every other node type.
+ $ref: '#/components/schemas/workspace.v1.UsersetTree'
+ exclude:
+ title: exclude
+ $ref: '#/components/schemas/workspace.v1.UsersetTree'
title: UsersetTree
additionalProperties: false
workspace.v1.Workspace:
diff --git a/internal/connect/authz.go b/internal/connect/authz.go
index f771a74..f23c9db 100644
--- a/internal/connect/authz.go
+++ b/internal/connect/authz.go
@@ -248,9 +248,11 @@ func treeToProto(t authz.Tree) *workspacev1.UsersetTree {
node.Children = append(node.Children, treeToProto(c))
}
case t.Exclude != nil:
- // EXCLUSION carries exactly two children: [include, exclude].
+ // EXCLUSION encodes its operands explicitly: include minus exclude.
+ // children stays empty.
node.Type = workspacev1.UsersetTree_NODE_TYPE_EXCLUSION
- node.Children = append(node.Children, treeToProto(t.Exclude.Include), treeToProto(t.Exclude.Exclude))
+ node.Include = treeToProto(t.Exclude.Include)
+ node.Exclude = treeToProto(t.Exclude.Exclude)
default:
node.Type = workspacev1.UsersetTree_NODE_TYPE_LEAF
node.UserIds = append(node.UserIds, t.Users...)
diff --git a/internal/connect/expand_tree_test.go b/internal/connect/expand_tree_test.go
new file mode 100644
index 0000000..01d75b3
--- /dev/null
+++ b/internal/connect/expand_tree_test.go
@@ -0,0 +1,72 @@
+package connect
+
+import (
+ "testing"
+
+ workspacev1 "github.com/elloloop/workspace/gen/go/workspace/v1"
+ "github.com/elloloop/workspace/pkg/authz"
+)
+
+// TestTreeToProtoExclusionExplicit pins that an exclusion Tree is converted to a
+// proto node that encodes its operands EXPLICITLY in include/exclude, never by
+// child position, while union/intersection still use children.
+func TestTreeToProtoExclusionExplicit(t *testing.T) {
+ incSet := authz.SubjectSet{Namespace: "doc", ObjectID: "d1", Relation: "member"}
+ excSet := authz.SubjectSet{Namespace: "doc", ObjectID: "d1", Relation: "suspended"}
+ tree := authz.Tree{
+ Expanded: authz.SubjectSet{Namespace: "doc", ObjectID: "d1", Relation: "active_member"},
+ Exclude: &authz.ExcludeTree{
+ Include: authz.Tree{Expanded: incSet, Users: []string{"u1"}},
+ Exclude: authz.Tree{Expanded: excSet, Users: []string{"u2"}},
+ },
+ }
+
+ got := treeToProto(tree)
+
+ if got.Type != workspacev1.UsersetTree_NODE_TYPE_EXCLUSION {
+ t.Fatalf("type = %v, want EXCLUSION", got.Type)
+ }
+ if len(got.Children) != 0 {
+ t.Fatalf("children = %d, want 0 for EXCLUSION", len(got.Children))
+ }
+ if got.Include == nil || got.Exclude == nil {
+ t.Fatalf("include/exclude must be set, got include=%v exclude=%v", got.Include, got.Exclude)
+ }
+ if got.Include.Expanded.GetRelation() != "member" {
+ t.Fatalf("include relation = %q, want member", got.Include.Expanded.GetRelation())
+ }
+ if got.Exclude.Expanded.GetRelation() != "suspended" {
+ t.Fatalf("exclude relation = %q, want suspended", got.Exclude.Expanded.GetRelation())
+ }
+ if len(got.Include.UserIds) != 1 || got.Include.UserIds[0] != "u1" {
+ t.Fatalf("include users = %v, want [u1]", got.Include.UserIds)
+ }
+ if len(got.Exclude.UserIds) != 1 || got.Exclude.UserIds[0] != "u2" {
+ t.Fatalf("exclude users = %v, want [u2]", got.Exclude.UserIds)
+ }
+}
+
+// TestTreeToProtoUnionUsesChildren confirms non-exclusion nodes keep using
+// children and leave include/exclude unset.
+func TestTreeToProtoUnionUsesChildren(t *testing.T) {
+ self := authz.SubjectSet{Namespace: "doc", ObjectID: "d1", Relation: "viewer"}
+ tree := authz.Tree{
+ Expanded: self,
+ Union: []authz.Tree{
+ {Expanded: self, Users: []string{"a"}},
+ {Expanded: self, Users: []string{"b"}},
+ },
+ }
+
+ got := treeToProto(tree)
+
+ if got.Type != workspacev1.UsersetTree_NODE_TYPE_UNION {
+ t.Fatalf("type = %v, want UNION", got.Type)
+ }
+ if len(got.Children) != 2 {
+ t.Fatalf("children = %d, want 2", len(got.Children))
+ }
+ if got.Include != nil || got.Exclude != nil {
+ t.Fatalf("include/exclude must be unset for UNION, got include=%v exclude=%v", got.Include, got.Exclude)
+ }
+}
diff --git a/proto/workspace/v1/workspace.proto b/proto/workspace/v1/workspace.proto
index 075e218..6364d00 100644
--- a/proto/workspace/v1/workspace.proto
+++ b/proto/workspace/v1/workspace.proto
@@ -651,19 +651,27 @@ message UsersetTree {
NODE_TYPE_UNION = 1;
NODE_TYPE_LEAF = 2;
NODE_TYPE_INTERSECTION = 3;
- // EXCLUSION nodes carry exactly two children: [include, exclude].
+ // EXCLUSION encodes its operands EXPLICITLY in `include` / `exclude`, NOT
+ // in `children`: the effective set is `include` minus `exclude`. `children`
+ // is empty for EXCLUSION nodes.
NODE_TYPE_EXCLUSION = 4;
}
NodeType type = 1;
// For LEAF nodes: the concrete subjects and usersets at this node.
repeated string user_ids = 2;
repeated SubjectSet sets = 3;
- // For UNION / INTERSECTION / EXCLUSION nodes: the child subtrees.
+ // For UNION / INTERSECTION nodes: the child subtrees. Empty for EXCLUSION
+ // (which uses `include` / `exclude` instead).
repeated UsersetTree children = 4;
// The userset this node expands.
SubjectSet expanded = 5;
// For LEAF nodes: true if the public wildcard subject is present.
bool wildcard = 6;
+ // For EXCLUSION nodes only: the include leg (the base set) and the exclude
+ // leg (subjects removed from it). The effective set is `include` minus
+ // `exclude`. Unset for every other node type.
+ UsersetTree include = 7;
+ UsersetTree exclude = 8;
}
message ExpandResponse { UsersetTree tree = 1; }
diff --git a/tests/expand_e2e_test.go b/tests/expand_e2e_test.go
index ba862e5..d31ddad 100644
--- a/tests/expand_e2e_test.go
+++ b/tests/expand_e2e_test.go
@@ -62,12 +62,19 @@ func TestExpandSerializesNewNodeTypes(t *testing.T) {
if published.Type != workspacev1.UsersetTree_NODE_TYPE_EXCLUSION {
t.Fatalf("published type = %v, want EXCLUSION", published.Type)
}
- if len(published.Children) != 2 {
- t.Fatalf("EXCLUSION children = %d, want 2 (include, exclude)", len(published.Children))
+ // EXCLUSION encodes operands explicitly in include/exclude, never children.
+ if len(published.Children) != 0 {
+ t.Fatalf("EXCLUSION children = %d, want 0 (operands live in include/exclude)", len(published.Children))
}
- // children[0] is the include leg (public) and must carry the wildcard.
- if !published.Children[0].Wildcard {
- t.Fatal("EXCLUSION children[0] (include) should carry the public wildcard")
+ if published.Include == nil || published.Exclude == nil {
+ t.Fatalf("EXCLUSION include/exclude must be set, got include=%v exclude=%v", published.Include, published.Exclude)
+ }
+ // The include leg (public) must carry the wildcard; the exclude leg must not.
+ if !published.Include.Wildcard {
+ t.Fatal("EXCLUSION include leg should carry the public wildcard")
+ }
+ if published.Exclude.Wildcard {
+ t.Fatal("EXCLUSION exclude leg should not carry the public wildcard")
}
premium := expand("premium")