Files
substrate/cmd/ateapi/internal/controlapi/service.go
T
Shruti Nair e9a31a9f51 ateapi: Implement Policy Governance CRUD Access Policy APIs (#1965)
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.
2026-10-02 20:47:03 +00:00

201 lines
9.6 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 (
"context"
"sync"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache"
"github.com/agent-substrate/substrate/internal/localca"
"github.com/agent-substrate/substrate/internal/localjwtauthority"
"github.com/agent-substrate/substrate/internal/objectstore"
"github.com/agent-substrate/substrate/internal/resources"
"github.com/agent-substrate/substrate/internal/volume"
"github.com/agent-substrate/substrate/internal/volume/csi"
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
storagev1listers "k8s.io/client-go/listers/storage/v1"
)
// RPCService implements ateapipb.ControlServer and provides the implementation of
// the RPC service.
//
// Methods on this service should be as light as possible, delegating to the
// ServiceImpl for business logic and invariants.
type RPCService struct {
ateapipb.UnimplementedControlServer
impl serviceStore
persistence serviceStore
workerCache *workercache.Cache
dialer *AteletDialer
sandboxConfigLister listersv1alpha1.SandboxConfigLister
csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister
actorWorkflow *ActorWorkflow
workerWorkflow *WorkerWorkflow
instruments *Instruments
mu sync.RWMutex
volumePlugins map[string]volume.VolumePluginControlPlane
objectStore objectstore.Store
actorJWTIssuer string
actorIDJWTPool localjwtauthority.Pool
actorIDCAPool localca.Pool
}
var _ ateapipb.ControlServer = (*RPCService)(nil)
// VolumePluginRegistry defines the interface for dynamic CSI plugin resolution.
type VolumePluginRegistry interface {
GetPlugin(ctx context.Context, name string) (volume.VolumePluginControlPlane, error)
}
// NewRPCService creates an instance of the ControlServer service. This is what
// implements the outward-facing RPC interface.
//
// instruments may be nil; the record helpers no-op.
//
// objectStore may be nil, which leaves external snapshots in place instead of
// copying and releasing them. Only tests that never reach those steps pass nil;
// ate-api always builds one.
//
// actorJWTIssuer is copied verbatim into the iss claim of every actor JWT.
func NewRPCService(
persistence store.Interface,
workerCache *workercache.Cache,
sandboxConfigLister listersv1alpha1.SandboxConfigLister,
csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister,
storageClassLister storagev1listers.StorageClassLister,
dialer *AteletDialer,
instruments *Instruments,
egressGatewayAddress string,
volumePlugins map[string]volume.VolumePluginControlPlane,
objectStore objectstore.Store,
actorJWTIssuer string,
actorIDJWTPool localjwtauthority.Pool,
actorIDCAPool localca.Pool,
) *RPCService {
impl := newServiceImpl(persistence, storageClassLister)
s := &RPCService{
impl: impl,
persistence: persistence,
workerCache: workerCache,
sandboxConfigLister: sandboxConfigLister,
csiDriverConfigLister: csiDriverConfigLister,
dialer: dialer,
instruments: instruments,
volumePlugins: volumePlugins,
objectStore: objectStore,
actorJWTIssuer: actorJWTIssuer,
actorIDJWTPool: actorIDJWTPool,
actorIDCAPool: actorIDCAPool,
}
s.actorWorkflow = NewActorWorkflow(impl, workerCache, dialer, sandboxConfigLister, storageClassLister, instruments, egressGatewayAddress, s, objectStore)
s.workerWorkflow = NewWorkerWorkflow(impl)
return s
}
// serviceStore enumerates the exact storage methods needed by
// the control API and nothing more.
type serviceStore interface {
CreateActor(ctx context.Context, actor *ateapipb.Actor) (*ateapipb.Actor, error)
GetActor(ctx context.Context, actorRef resources.ActorRef) (*ateapipb.Actor, error)
UpdateActor(ctx context.Context, actorRef resources.ActorRef, precondition store.Precondition, mutate func(toUpdate *ateapipb.Actor) error) (*ateapipb.Actor, error)
ListActors(ctx context.Context, atespace string, opts store.ListOptions) (store.ListResponse[*ateapipb.Actor], error)
CreateEgressPolicy(ctx context.Context, actorRef resources.ActorRef, policy *ateapipb.EgressPolicy) (*ateapipb.EgressPolicy, error)
GetEgressPolicy(ctx context.Context, actorRef resources.ActorRef) (*ateapipb.EgressPolicy, error)
UpdateEgressPolicy(ctx context.Context, actorRef resources.ActorRef, precondition store.Precondition, mutate func(*ateapipb.EgressPolicy) error) (*ateapipb.EgressPolicy, error)
DeleteEgressPolicy(ctx context.Context, actorRef resources.ActorRef, precondition store.DeletePreconditions) (*ateapipb.EgressPolicy, error)
GetTag(ctx context.Context, tagRef resources.TagRef) (*ateapipb.Tag, error)
ListTags(ctx context.Context, atespace string, opts store.ListOptions) (store.ListResponse[*ateapipb.Tag], error)
UpdateTag(ctx context.Context, tagRef resources.TagRef, precondition store.Precondition, mutate func(toUpdate *ateapipb.Tag) error) (*ateapipb.Tag, error)
DeleteTag(ctx context.Context, tagRef resources.TagRef, precondition store.DeletePreconditions) (*ateapipb.Tag, error)
CreateAtespace(ctx context.Context, atespace *ateapipb.Atespace) (*ateapipb.Atespace, error)
GetAtespace(ctx context.Context, name string) (*ateapipb.Atespace, error)
ListAtespaces(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapipb.Atespace], error)
DeleteAtespace(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapipb.Atespace, error)
CreateGlobalAccessPolicy(ctx context.Context, policy *ateapipb.AccessPolicy) (*ateapipb.AccessPolicy, error)
GetGlobalAccessPolicy(ctx context.Context) (*ateapipb.AccessPolicy, error)
UpdateGlobalAccessPolicy(ctx context.Context, precondition store.Precondition, mutate func(*ateapipb.AccessPolicy) error) (*ateapipb.AccessPolicy, error)
CreateAtespaceAccessPolicy(ctx context.Context, name string, policy *ateapipb.AccessPolicy) (*ateapipb.AccessPolicy, error)
GetAtespaceAccessPolicy(ctx context.Context, name string) (*ateapipb.AccessPolicy, error)
UpdateAtespaceAccessPolicy(ctx context.Context, name string, precondition store.Precondition, mutate func(*ateapipb.AccessPolicy) error) (*ateapipb.AccessPolicy, error)
DeleteAtespaceAccessPolicy(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapipb.AccessPolicy, error)
CreateActorTemplate(ctx context.Context, template *ateapipb.ActorTemplate) (*ateapipb.ActorTemplate, error)
GetActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef) (*ateapipb.ActorTemplate, error)
ListActorTemplates(ctx context.Context, atespace string, opts store.ListOptions) (store.ListResponse[*ateapipb.ActorTemplate], error)
DeleteActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef, precondition store.DeletePreconditions) (*ateapipb.ActorTemplate, error)
ListWorkers(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapipb.Worker], error)
GetWorker(ctx context.Context, name string) (*ateapipb.Worker, error)
ListWorkerAssignments(ctx context.Context, workerName string, opts store.ListOptions) (store.ListResponse[*ateapipb.ActorAssignment], error)
CreateWorker(ctx context.Context, worker *ateapipb.Worker) (*ateapipb.Worker, error)
UpdateWorker(ctx context.Context, name string, precondition store.Precondition, mutate func(toUpdate *ateapipb.Worker) error) (*ateapipb.Worker, error)
AcquireLease(ctx context.Context, key string) (*store.Lease, error)
}
// GetPlugin retrieves a CSI volume plugin by driver name, dynamically discovering it if not present.
func (s *RPCService) GetPlugin(ctx context.Context, driverName string) (volume.VolumePluginControlPlane, error) {
s.mu.RLock()
plugin, ok := s.volumePlugins[driverName]
s.mu.RUnlock()
if ok {
return plugin, nil
}
csiPlugin, err := csi.NewCSIPlugin(ctx, s.csiDriverConfigLister, driverName, true /*isController*/)
if err != nil {
return nil, err
}
s.mu.Lock()
s.volumePlugins[driverName] = csiPlugin
s.mu.Unlock()
return csiPlugin, nil
}
// ServiceImpl implements store.Interface and provides the "middleware" layer
// between the RPC and storage layers. It enforces invariants and validation
// rules, and may implement additional logic beyond the storage layer.
//
// Methods on this service should hold most of the logic.
type ServiceImpl struct {
// This field is explicitly named to prevent accidentally satisfying
// methods we need to trap.
store store.Interface
storageClassLister storagev1listers.StorageClassLister
}
var _ store.Interface = (*ServiceImpl)(nil)
// newServiceImpl creates an instance of the service's middleware
// implementation layer.
func newServiceImpl(
persistence store.Interface,
storageClassLister storagev1listers.StorageClassLister,
) *ServiceImpl {
s := &ServiceImpl{
store: persistence,
storageClassLister: storageClassLister,
}
return s
}
// Pass-through.
func (s *ServiceImpl) AcquireLease(ctx context.Context, key string) (*store.Lease, error) {
return s.store.AcquireLease(ctx, key)
}