mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-02 03:24:42 +08:00
Working on https://github.com/agent-substrate/substrate/issues/1563 ## Summary Initializes an embedded OpenFGA server within `ateapi` backed by PostgreSQL and updates the top-level authorization scope. ## Key Changes - **Rename scope to `global`**: Renamed `type cluster` and `parent_cluster` to `type global` and `parent_global`, adding `can_set_policy` and `can_get_policy` to `global`. - **Embedded OpenFGA server (`internal/authz/server.go`)**: - Compiles `model.fga` into OpenFGA's protobuf representation. - Runs OpenFGA PostgreSQL schema migrations (`goose_db_version`). - Serializes startup via `pg_advisory_lock` to avoid multi-replica races. - Connects OpenFGA's PostgreSQL adapter with a new dedicated `*pgxpool.Pool`. - Idempotently creates or reuses the `"substrate"` store and authorization model. - **Service wiring (`cmd/ateapi`)**: Exposes `Pool()` on `*atepg.Persistence` and initializes `authz.NewServer` on bootstrap. ## Verification - `openfga model test`: 103/103 checks passing (`model_test.fga.yaml`). - `go test -mod=mod ./internal/authz/...`: Passes against PostgreSQL (migrations, tuple writes, permission checks, and restart idempotency).
303 lines
9.1 KiB
Go
303 lines
9.1 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 authz
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"sync"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/jackc/pgx/v5/stdlib"
|
|
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
|
"github.com/openfga/language/pkg/go/transformer"
|
|
"github.com/openfga/openfga/assets"
|
|
"github.com/openfga/openfga/pkg/server"
|
|
"github.com/openfga/openfga/pkg/storage"
|
|
"github.com/openfga/openfga/pkg/storage/postgres"
|
|
"github.com/openfga/openfga/pkg/storage/sqlcommon"
|
|
"github.com/pressly/goose/v3"
|
|
"github.com/pressly/goose/v3/lock"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/types/known/wrapperspb"
|
|
)
|
|
|
|
const (
|
|
// DefaultStoreName is the name of the OpenFGA store managed by Substrate.
|
|
DefaultStoreName = "substrate"
|
|
|
|
// migrationTableName tracks OpenFGA schema migrations separately from
|
|
// Substrate's own schema_migrations table.
|
|
migrationTableName = "goose_db_version"
|
|
)
|
|
|
|
//go:embed model.fga
|
|
var modelDSL string
|
|
|
|
// Server wraps an embedded OpenFGA server backed by PostgreSQL and
|
|
// initialized with Substrate's authorization model.
|
|
type Server struct {
|
|
closeOnce sync.Once
|
|
fgaServer *server.Server
|
|
datastore storage.OpenFGADatastore
|
|
storeID string
|
|
modelID string
|
|
}
|
|
|
|
// NewServer initializes OpenFGA database migrations on pool, constructs the
|
|
// PostgreSQL storage adapter, creates the OpenFGA server, and ensures the
|
|
// default store and checked-in authorization model are present.
|
|
//
|
|
// NewServer takes ownership of pool: calling Close on the returned Server (or
|
|
// an error during NewServer initialization) closes pool. Callers must provide
|
|
// a dedicated pool rather than a shared pool.
|
|
func NewServer(ctx context.Context, pool *pgxpool.Pool) (*Server, error) {
|
|
if pool == nil {
|
|
return nil, fmt.Errorf("postgres pool must not be nil")
|
|
}
|
|
|
|
// Ensure OpenFGA database tables (tuple, store, authorization_model, changelog)
|
|
// are migrated and ready in PostgreSQL before initializing the storage adapter.
|
|
// Goose uses PostgresSessionLocker to serialize migrations safely across replicas.
|
|
if err := applyMigrations(ctx, pool); err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("applying OpenFGA migrations: %w", err)
|
|
}
|
|
|
|
cfg := sqlcommon.NewConfig()
|
|
datastore, err := postgres.NewWithDB(pool, nil, cfg)
|
|
if err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("creating OpenFGA postgres adapter: %w", err)
|
|
}
|
|
|
|
fgaServer, err := server.NewServerWithOpts(
|
|
server.WithDatastore(datastore),
|
|
)
|
|
if err != nil {
|
|
datastore.Close()
|
|
return nil, fmt.Errorf("creating OpenFGA server: %w", err)
|
|
}
|
|
|
|
unlock, err := acquireInitLock(ctx, pool)
|
|
if err != nil {
|
|
fgaServer.Close()
|
|
datastore.Close()
|
|
return nil, err
|
|
}
|
|
|
|
storeID, modelID, err := ensureStoreAndModel(ctx, fgaServer)
|
|
unlock()
|
|
if err != nil {
|
|
fgaServer.Close()
|
|
datastore.Close()
|
|
return nil, fmt.Errorf("initializing OpenFGA store and model: %w", err)
|
|
}
|
|
|
|
slog.InfoContext(ctx, "OpenFGA server initialized",
|
|
slog.String("store_id", storeID),
|
|
slog.String("model_id", modelID),
|
|
)
|
|
|
|
return &Server{
|
|
fgaServer: fgaServer,
|
|
datastore: datastore,
|
|
storeID: storeID,
|
|
modelID: modelID,
|
|
}, nil
|
|
}
|
|
|
|
// FGAServer returns the underlying OpenFGA server instance.
|
|
func (s *Server) FGAServer() *server.Server {
|
|
return s.fgaServer
|
|
}
|
|
|
|
// StoreID returns the active OpenFGA store ID.
|
|
func (s *Server) StoreID() string {
|
|
return s.storeID
|
|
}
|
|
|
|
// ModelID returns the active OpenFGA authorization model ID.
|
|
func (s *Server) ModelID() string {
|
|
return s.modelID
|
|
}
|
|
|
|
// Close releases resources held by the OpenFGA server and datastore.
|
|
// Calling Close multiple times is safe and idempotent.
|
|
func (s *Server) Close() {
|
|
s.closeOnce.Do(func() {
|
|
if s.fgaServer != nil {
|
|
s.fgaServer.Close()
|
|
}
|
|
if s.datastore != nil {
|
|
s.datastore.Close()
|
|
}
|
|
})
|
|
}
|
|
|
|
// ateFGAInitLockID is a 64-bit identifier ("atefga") for serializing
|
|
// OpenFGA store provisioning across replicas.
|
|
const ateFGAInitLockID = int64(0x6174656667610000) // "atefga\0\0"
|
|
|
|
func acquireInitLock(ctx context.Context, pool *pgxpool.Pool) (func(), error) {
|
|
conn, err := pool.Acquire(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acquiring connection for OpenFGA init lock: %w", err)
|
|
}
|
|
if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, ateFGAInitLockID); err != nil {
|
|
conn.Release()
|
|
return nil, fmt.Errorf("acquiring OpenFGA init advisory lock: %w", err)
|
|
}
|
|
return func() {
|
|
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, ateFGAInitLockID)
|
|
conn.Release()
|
|
}, nil
|
|
}
|
|
|
|
// applyMigrations runs OpenFGA's embedded PostgreSQL migrations against pool
|
|
// using Goose, tracking applied migration versions in the goose_db_version table.
|
|
func applyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
|
migrations, err := fs.Sub(assets.EmbedMigrations, assets.PostgresMigrationDir)
|
|
if err != nil {
|
|
return fmt.Errorf("open embedded OpenFGA migrations: %w", err)
|
|
}
|
|
|
|
locker, err := lock.NewPostgresSessionLocker(
|
|
lock.WithLockID(ateFGAInitLockID),
|
|
lock.WithLockTimeout(1, 300),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("create OpenFGA migration locker: %w", err)
|
|
}
|
|
|
|
db := stdlib.OpenDBFromPool(pool)
|
|
provider, err := goose.NewProvider(
|
|
goose.DialectPostgres,
|
|
db,
|
|
migrations,
|
|
goose.WithTableName(migrationTableName),
|
|
goose.WithSessionLocker(locker),
|
|
)
|
|
if err != nil {
|
|
_ = db.Close()
|
|
return fmt.Errorf("create OpenFGA migration provider: %w", err)
|
|
}
|
|
|
|
_, err = provider.Up(ctx)
|
|
closeErr := provider.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("run OpenFGA migrations: %w", err)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close OpenFGA migration provider: %w", closeErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureStoreAndModel compiles the embedded model.fga DSL into an OpenFGA proto,
|
|
// finds or creates the default store, and ensures the authorization model matches
|
|
// the current schema. If an identical model already exists in the store, its ID
|
|
// is reused; otherwise, the new model is written and its ID is returned.
|
|
func ensureStoreAndModel(ctx context.Context, srv *server.Server) (string, string, error) {
|
|
modelProto, err := transformer.TransformDSLToProto(modelDSL)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("transform model.fga DSL to proto: %w", err)
|
|
}
|
|
|
|
storeID, err := findOrCreateStore(ctx, srv, DefaultStoreName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
modelsResp, err := srv.ReadAuthorizationModels(ctx, &openfgav1.ReadAuthorizationModelsRequest{
|
|
StoreId: storeID,
|
|
PageSize: wrapperspb.Int32(1),
|
|
})
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("read existing authorization models: %w", err)
|
|
}
|
|
|
|
if len(modelsResp.GetAuthorizationModels()) > 0 {
|
|
latest := modelsResp.GetAuthorizationModels()[0]
|
|
if modelsEqual(latest, modelProto) {
|
|
return storeID, latest.GetId(), nil
|
|
}
|
|
}
|
|
|
|
writeResp, err := srv.WriteAuthorizationModel(ctx, &openfgav1.WriteAuthorizationModelRequest{
|
|
StoreId: storeID,
|
|
SchemaVersion: modelProto.GetSchemaVersion(),
|
|
TypeDefinitions: modelProto.GetTypeDefinitions(),
|
|
Conditions: modelProto.GetConditions(),
|
|
})
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("write authorization model: %w", err)
|
|
}
|
|
|
|
return storeID, writeResp.GetAuthorizationModelId(), nil
|
|
}
|
|
|
|
// findOrCreateStore looks up an existing OpenFGA store by name across all pages.
|
|
// If found, its existing store ID is returned to ensure idempotency across restarts.
|
|
// If no store with the given name exists, a new store is created and returned.
|
|
func findOrCreateStore(ctx context.Context, srv *server.Server, name string) (string, error) {
|
|
var continuationToken string
|
|
for {
|
|
listResp, err := srv.ListStores(ctx, &openfgav1.ListStoresRequest{
|
|
ContinuationToken: continuationToken,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("list stores: %w", err)
|
|
}
|
|
for _, st := range listResp.GetStores() {
|
|
if st.GetName() == name {
|
|
return st.GetId(), nil
|
|
}
|
|
}
|
|
if listResp.GetContinuationToken() == "" {
|
|
break
|
|
}
|
|
continuationToken = listResp.GetContinuationToken()
|
|
}
|
|
|
|
createResp, err := srv.CreateStore(ctx, &openfgav1.CreateStoreRequest{
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create store %q: %w", name, err)
|
|
}
|
|
return createResp.GetId(), nil
|
|
}
|
|
|
|
func modelsEqual(existing, desired *openfgav1.AuthorizationModel) bool {
|
|
if existing.GetSchemaVersion() != desired.GetSchemaVersion() {
|
|
return false
|
|
}
|
|
a := &openfgav1.AuthorizationModel{
|
|
SchemaVersion: existing.GetSchemaVersion(),
|
|
TypeDefinitions: existing.GetTypeDefinitions(),
|
|
Conditions: existing.GetConditions(),
|
|
}
|
|
b := &openfgav1.AuthorizationModel{
|
|
SchemaVersion: desired.GetSchemaVersion(),
|
|
TypeDefinitions: desired.GetTypeDefinitions(),
|
|
Conditions: desired.GetConditions(),
|
|
}
|
|
return proto.Equal(a, b)
|
|
}
|