mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-10 10:13:50 +08:00
Working on https://github.com/agent-substrate/substrate/issues/1563 Fixes https://github.com/agent-substrate/substrate/issues/1697, https://github.com/agent-substrate/substrate/issues/1701 ## Summary Adds `AccessPolicy` singleton subresources for the Global (`global:root`) and `Atespace` scopes, stored in PostgreSQL and enforced through OpenFGA. ### Changes - **API (`Control` service)** - Global: `CreateGlobalAccessPolicy`, `GetGlobalAccessPolicy`, `UpdateGlobalAccessPolicy` (no Delete; an update with empty bindings clears all grants). - Atespace: `CreateAtespaceAccessPolicy`, `GetAtespaceAccessPolicy`, `UpdateAtespaceAccessPolicy`, `DeleteAtespaceAccessPolicy`. - `Binding.members` has set semantics. - **Always-wired authz**: the OpenFGA server, `Authorizer` and `PolicyManager` are always created. `--experimental-enable-authz` only controls whether the interceptor enforces checks on regular RPCs. Policy tuples therefore stay in sync with the store in every mode, and deleting an atespace always removes its tuples. - **Governance RPCs are always enforced**: AccessPolicy RPCs are marked `alwaysEnforce` in the permission registry and are checked even when enforcement is off, so nobody can grant themselves access before it is turned on. A test fails if an AccessPolicy RPC is added without that marker. - **Bootstrap owners**: `--authz-bootstrap-owners` lists server-configured owners (required when enforcing). They are never written to the store: `Authorizer.Check` adds a contextual `owner global:root` tuple for the calling user only, so access follows the server config and is dropped on restart without them. A bootstrap owner creates the global policy. - **Storage**: migration `000003` adds `global_access_policy` and `atespace_access_policies`. Deleting an atespace cascades to its policy row and removes its OpenFGA tuples in the same transaction. - **Consistency**: `PolicyManager` takes an explicit `pgx.Tx`, locks the metadata row (`FOR UPDATE`) and reconciles OpenFGA tuples in the same transaction. Optimistic concurrency via `uid` / `version`. - **Hardening**: members with control characters are rejected, as are blank, `*`, or control-character bootstrap owner entries; non-canonical OpenFGA error codes are mapped to `Internal`. ### Testing - Interceptor tests for both modes: with enforcement off, regular RPCs skip the check while governance RPCs are still checked. - Functional CRUD tests for both AccessPolicy scopes (`functionaltest/access_policy_test.go`). - PostgreSQL test that a policy update and its tuple writes commit or roll back together, including under concurrent updates. - Governance tests: creating the policy as a bootstrap owner, a bootstrap owner also stored as owner, and a restart without a bootstrap owner. ### Notes - ateapi now needs OpenFGA available at startup even with enforcement off, and atespace deletes always write OpenFGA tuples. - TODO: move the authz settings into the hot-reloadable config proto (#2021) once it lands.
51 lines
1.7 KiB
Go
51 lines
1.7 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package controlapi
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
func TestMapAccessPolicyWrite_Codes(t *testing.T) {
|
|
// OpenFGA reports tuple validation failures with nonstandard codes such as 2000.
|
|
openFGAErr := status.Error(codes.Code(2000), "invalid tuple")
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want codes.Code
|
|
}{
|
|
{name: "nonstandard backend code", err: openFGAErr, want: codes.Internal},
|
|
{name: "wrapped nonstandard backend code", err: fmt.Errorf("reconciling: %w", openFGAErr), want: codes.Internal},
|
|
{name: "canonical status passes through", err: status.Error(codes.InvalidArgument, "bad"), want: codes.InvalidArgument},
|
|
{name: "plain error", err: errors.New("boom"), want: codes.Unknown},
|
|
{name: "not found", err: store.ErrNotFound, want: codes.NotFound},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := mapAccessPolicyWrite(nil, tc.err)
|
|
if got := status.Code(err); got != tc.want {
|
|
t.Errorf("mapAccessPolicyWrite(%v) code = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|