Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 44 additions & 15 deletions edgraph/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,12 @@ func parseSchemaFromAlterOperation(ctx context.Context, sch string) (
// there are pre-defined predicates (subset of reserved predicates), and for them we allow
// the schema update to go through if the update is equal to the existing one.
// So, here we check if the predicate is reserved but not pre-defined to block users from
// creating predicates in reserved namespace.
if x.IsReservedPredicate(update.Predicate) && !x.IsPreDefinedPredicate(update.Predicate) {
// creating predicates in reserved namespace. A predicate owned by a registered
// ReservedNamespace (see x.RegisterReservedNamespace) is allowed through, so a plugin can
// create its own predicates under `dgraph.` at runtime.
if x.IsReservedPredicate(update.Predicate) &&
!x.IsPreDefinedPredicate(update.Predicate) &&
!x.IsRegisteredReservedPredicate(update.Predicate) {
return nil, errors.Errorf("Can't alter predicate `%s` as it is prefixed with `dgraph.`"+
" which is reserved as the namespace for dgraph's internal types/predicates.",
x.ParseAttr(update.Predicate))
Expand All @@ -310,7 +314,10 @@ func parseSchemaFromAlterOperation(ctx context.Context, sch string) (

// Users are not allowed to create types in reserved namespace. But, there are pre-defined
// types for which the update should go through if the update is equal to the existing one.
if x.IsReservedType(typ.TypeName) && !x.IsPreDefinedType(typ.TypeName) {
// A type owned by a registered ReservedNamespace is allowed through like its predicate
// counterparts, so a plugin can declare its own types under `dgraph.` at runtime.
if x.IsReservedType(typ.TypeName) && !x.IsPreDefinedType(typ.TypeName) &&
!x.IsRegisteredReservedType(typ.TypeName) {
return nil, errors.Errorf("Can't alter type `%s` as it is prefixed with `dgraph.` "+
"which is reserved as the namespace for dgraph's internal types/predicates.",
x.ParseAttr(typ.TypeName))
Expand Down Expand Up @@ -1706,7 +1713,7 @@ func parseRequest(ctx context.Context, qc *queryContext) error {
// parsing mutations
qc.gmuList = make([]*dql.Mutation, 0, len(qc.req.Mutations))
for _, mu := range qc.req.Mutations {
gmu, err := ParseMutationObject(mu, qc.graphql)
gmu, err := ParseMutationObject(ctx, mu)
if err != nil {
return err
}
Expand Down Expand Up @@ -2181,7 +2188,7 @@ func hasPoormansAuth(ctx context.Context) error {
// api.Mutation#SetJson, api.Mutation#SetNquads and api.Mutation#Set are consolidated into the
// dql.Mutation.Set field. Similarly the 3 fields api.Mutation#DeleteJson, api.Mutation#DelNquads
// and api.Mutation#Del are merged into the dql.Mutation#Del field.
func ParseMutationObject(mu *api.Mutation, isGraphql bool) (*dql.Mutation, error) {
func ParseMutationObject(ctx context.Context, mu *api.Mutation) (*dql.Mutation, error) {
res := &dql.Mutation{Cond: mu.Cond}

if len(mu.SetJson) > 0 {
Expand Down Expand Up @@ -2225,7 +2232,7 @@ func ParseMutationObject(mu *api.Mutation, isGraphql bool) (*dql.Mutation, error
return nil, err
}

if err := validateNQuads(res.Set, res.Del, isGraphql); err != nil {
if err := validateNQuads(res.Set, res.Del, newReservedPredicateGuard(ctx)); err != nil {
return nil, err
}
return res, nil
Expand All @@ -2252,16 +2259,38 @@ func validateAndConvertFacets(nquads []*api.NQuad) error {
return nil
}

// validateForOtherReserved validate nquads for other reserved predicates
func validateForOtherReserved(nq *api.NQuad, isGraphql bool) error {
// Check whether the incoming predicate is other reserved predicate.
if !isGraphql && x.IsOtherReservedPredicate(nq.Predicate) {
return errors.Errorf("Cannot mutate graphql reserved predicate %s", nq.Predicate)
// reservedPredicateGuard reports an error if nq mutates the value of a reserved
// predicate the current request is not permitted to write. It is built once per
// request from the request context; see newReservedPredicateGuard.
type reservedPredicateGuard func(nq *api.NQuad) error

// newReservedPredicateGuard builds the per-request reserved-value guard. The
// GraphQL admin path (IsGraphql) owns the dgraph.graphql.* predicates. A
// registered ReservedNamespace may additionally value-lock predicates to its
// own trusted writer (see x.RegisterReservedNamespace): such a predicate may be
// written only when the request context carries the namespace's TrustMarker.
// Every other caller is blocked.
func newReservedPredicateGuard(ctx context.Context) reservedPredicateGuard {
isGraphql, _ := ctx.Value(IsGraphql).(bool)
return func(nq *api.NQuad) error {
if !isGraphql && x.IsOtherReservedPredicate(nq.Predicate) {
return errors.Errorf("Cannot mutate graphql reserved predicate %s", nq.Predicate)
}
if marker, locked := x.ReservedPredicateValueLock(nq.Predicate); locked {
trusted := false
if marker != nil {
trusted, _ = ctx.Value(marker).(bool)
}
if !trusted {
return errors.Errorf("Cannot mutate reserved predicate %s outside its "+
"owning service", nq.Predicate)
}
}
return nil
}
return nil
}

func validateNQuads(set, del []*api.NQuad, isGraphql bool) error {
func validateNQuads(set, del []*api.NQuad, guardReserved reservedPredicateGuard) error {
for _, nq := range set {
if err := validatePredName(nq.Predicate); err != nil {
return err
Expand All @@ -2276,7 +2305,7 @@ func validateNQuads(set, del []*api.NQuad, isGraphql bool) error {
if err := validateKeys(nq); err != nil {
return errors.Wrapf(err, "key error: %+v", nq)
}
if err := validateForOtherReserved(nq, isGraphql); err != nil {
if err := guardReserved(nq); err != nil {
return err
}
}
Expand All @@ -2291,7 +2320,7 @@ func validateNQuads(set, del []*api.NQuad, isGraphql bool) error {
if nq.Subject == x.Star || (nq.Predicate == x.Star && !ostar) {
return errors.Errorf("Only valid wildcard delete patterns are 'S * *' and 'S P *': %v", nq)
}
if err := validateForOtherReserved(nq, isGraphql); err != nil {
if err := guardReserved(nq); err != nil {
return err
}
// NOTE: we dont validateKeys() with delete to let users fix existing mistakes
Expand Down
6 changes: 4 additions & 2 deletions worker/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,10 @@ func (n *node) proposeAndWait(ctx context.Context, proposal *pb.Proposal) (perr
su, ok := schema.State().Get(ctx, edge.Attr)
if !ok {
// We don't allow mutations for reserved predicates if the schema for them doesn't
// already exist.
if x.IsReservedPredicate(edge.Attr) {
// already exist. A predicate owned by a registered ReservedNamespace
// (see x.RegisterReservedNamespace) is an exception — its owner registers it via
// Alter as schemas are written.
if x.IsReservedPredicate(edge.Attr) && !x.IsRegisteredReservedPredicate(edge.Attr) {
return errors.Errorf("Can't store predicate `%s` as it is prefixed with "+
"`dgraph.` which is reserved as the namespace for dgraph's internal "+
"types/predicates.",
Expand Down
112 changes: 112 additions & 0 deletions x/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"math"
"strconv"
"strings"
"sync"

"github.com/pkg/errors"

Expand Down Expand Up @@ -644,6 +645,117 @@ var aclPredicateMap = map[string]struct{}{
"dgraph.acl.rule": {},
}

// ReservedNamespace lets a plugin claim ownership of a sub-namespace under the
// reserved `dgraph.` prefix, so the predicates and types it owns can be created
// at runtime via Alter even though they are not part of the pre-defined initial
// schema. Register one from an init() with RegisterReservedNamespace.
//
// With no registration the registry is empty and every check below reports
// false, so a stock build keeps the pristine behavior: nothing under `dgraph.`
// may be created except the pre-defined names.
//
// Ownership is deliberately explicit — a dynamic prefix and/or an exact name
// allowlist — so registering a namespace cannot be used to admit arbitrary
// reserved predicates through /alter or /mutate.
type ReservedNamespace struct {
// All names below are bare (no namespace prefix); queries strip the
// namespace before matching, as the reserved-predicate checks already do.
//
// PredicatePrefix admits dynamically-named predicates by prefix (e.g.
// "dgraph.acme.rel."). Empty means the namespace owns no dynamic predicates.
PredicatePrefix string
// Predicates is the exact set of fixed predicate names the namespace owns.
Predicates []string
// Types is the exact set of type names the namespace owns.
Types []string
// ValueLocked is the subset of owned predicates whose stored *value* may
// only be written by a request whose context carries TrustMarker. Use it
// for predicates whose value is authoritative state the owner must control.
// Names are the bare predicate form (no namespace prefix), matched
// case-insensitively like Predicates above.
ValueLocked []string
// TrustMarker is a context key the owner's trusted in-process caller sets,
// via context.WithValue(ctx, TrustMarker, true), to authorize writing
// ValueLocked predicates. Required (non-nil) when ValueLocked is non-empty;
// RegisterReservedNamespace panics otherwise.
TrustMarker any
}

var (
reservedNsMu sync.RWMutex
reservedNsPrefixes []string
reservedNsPredicates = map[string]struct{}{}
reservedNsTypes = map[string]struct{}{}
reservedNsValueLocked = map[string]any{} // lowercased bare predicate -> TrustMarker
)

// RegisterReservedNamespace records a plugin's ownership of names under the
// reserved `dgraph.` prefix. Call it from an init(); it is safe for concurrent
// use but is expected to run before the server starts handling requests.
func RegisterReservedNamespace(ns ReservedNamespace) {
if len(ns.ValueLocked) > 0 && ns.TrustMarker == nil {
panic("x.RegisterReservedNamespace: ValueLocked is set but TrustMarker is nil; " +
"a value-locked predicate with no TrustMarker is unwritable by everyone, including its owner")
}
reservedNsMu.Lock()
defer reservedNsMu.Unlock()
if ns.PredicatePrefix != "" {
reservedNsPrefixes = append(reservedNsPrefixes, strings.ToLower(ns.PredicatePrefix))
}
for _, p := range ns.Predicates {
reservedNsPredicates[strings.ToLower(p)] = struct{}{}
}
for _, t := range ns.Types {
reservedNsTypes[strings.ToLower(t)] = struct{}{}
}
for _, p := range ns.ValueLocked {
reservedNsValueLocked[strings.ToLower(p)] = ns.TrustMarker
}
}

// IsRegisteredReservedPredicate reports whether pred is owned by a registered
// ReservedNamespace (by dynamic prefix or exact name). The alter validator and
// the no-schema mutation guard allow such predicates through even though they
// are not pre-defined. With no registration it always reports false.
func IsRegisteredReservedPredicate(pred string) bool {
p := strings.ToLower(ParseAttr(pred))
reservedNsMu.RLock()
defer reservedNsMu.RUnlock()
for _, prefix := range reservedNsPrefixes {
if strings.HasPrefix(p, prefix) {
return true
}
}
_, ok := reservedNsPredicates[p]
return ok
}

// IsRegisteredReservedType reports whether typ is a type owned by a registered
// ReservedNamespace. With no registration it always reports false.
func IsRegisteredReservedType(typ string) bool {
t := strings.ToLower(ParseAttr(typ))
reservedNsMu.RLock()
defer reservedNsMu.RUnlock()
_, ok := reservedNsTypes[t]
return ok
}

// ReservedPredicateValueLock reports whether pred's value is locked to a
// trusted writer and, if so, returns the context key (TrustMarker) that
// authorizes the write. The mutation-value guard rejects a write of a locked
// predicate when the request context does not carry that marker.
//
// pred is matched case-insensitively (like the predicate/type lookups above) so
// a value lock cannot be bypassed by changing the case of an owned name. Unlike
// those lookups it does not ParseAttr: the guard passes the bare predicate (no
// namespace separator), matching how IsOtherReservedPredicate is consulted.
func ReservedPredicateValueLock(pred string) (marker any, locked bool) {
reservedNsMu.RLock()
defer reservedNsMu.RUnlock()
marker, locked = reservedNsValueLocked[strings.ToLower(pred)]
return marker, locked
}

// TODO: rename this map to a better suited name as per its properties. It is not just for GraphQL
// predicates, but for all those which are PreDefined and whose value is not allowed to be mutated
// by users. When renaming this also rename the IsGraphql context key in edgraph/server.go.
Expand Down
95 changes: 95 additions & 0 deletions x/reserved_namespace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package x

import (
"testing"

"github.com/stretchr/testify/require"
)

type testTrustKey int

const testTrust testTrustKey = iota

// ns namespace-attributes a bare name, the way the alter validator and
// no-schema mutation guard see predicate/type names at the call sites.
func ns(name string) string { return NamespaceAttr(RootNamespace, name) }

func TestReservedNamespaceRegistry(t *testing.T) {
RegisterReservedNamespace(ReservedNamespace{
PredicatePrefix: "dgraph.testns.rel.",
Predicates: []string{"dgraph.testns.xid", "dgraph.testns.cfg"},
Types: []string{"dgraph.testns.node"},
ValueLocked: []string{"dgraph.testns.cfg"},
TrustMarker: testTrust,
})

// Dynamic prefix members.
require.True(t, IsRegisteredReservedPredicate(ns("dgraph.testns.rel.owner")))
// Exact predicate members.
require.True(t, IsRegisteredReservedPredicate(ns("dgraph.testns.xid")))
require.True(t, IsRegisteredReservedPredicate(ns("dgraph.testns.cfg")))
// Not owned: a sibling under the same prefix root, a different reserved
// namespace, and an ordinary predicate.
require.False(t, IsRegisteredReservedPredicate(ns("dgraph.testns.other")))
require.False(t, IsRegisteredReservedPredicate(ns("dgraph.graphql.schema")))
require.False(t, IsRegisteredReservedPredicate(ns("person.name")))

// Types are tracked separately from predicates.
require.True(t, IsRegisteredReservedType(ns("dgraph.testns.node")))
require.False(t, IsRegisteredReservedType(ns("dgraph.testns.xid"))) // a predicate, not a type
require.False(t, IsRegisteredReservedType(ns("dgraph.graphql")))

// Value lock matches the bare predicate, like IsOtherReservedPredicate.
// cfg is locked to the registered marker; xid is owned but not locked.
marker, locked := ReservedPredicateValueLock("dgraph.testns.cfg")
require.True(t, locked)
require.Equal(t, testTrust, marker)
_, locked = ReservedPredicateValueLock("dgraph.testns.xid")
require.False(t, locked)
}

// TestReservedNamespaceRejectsUnregistered confirms names no namespace claims
// are never members, so a stock build with no registration keeps the pristine
// reserved-namespace behavior (only pre-defined names exist under `dgraph.`).
func TestReservedNamespaceRejectsUnregistered(t *testing.T) {
require.False(t, IsRegisteredReservedPredicate(ns("dgraph.unregistered.pred")))
require.False(t, IsRegisteredReservedType(ns("dgraph.unregistered.type")))
_, locked := ReservedPredicateValueLock("dgraph.unregistered.pred")
require.False(t, locked)
}

// TestReservedPredicateValueLockCaseInsensitive guards against bypassing a value
// lock by changing the case of an owned name: ownership is matched
// case-insensitively, so the value lock must be too.
func TestReservedPredicateValueLockCaseInsensitive(t *testing.T) {
RegisterReservedNamespace(ReservedNamespace{
Predicates: []string{"dgraph.casetest.Secret"},
ValueLocked: []string{"dgraph.casetest.Secret"},
TrustMarker: testTrust,
})

for _, p := range []string{"dgraph.casetest.Secret", "dgraph.casetest.secret", "dgraph.casetest.SECRET"} {
marker, locked := ReservedPredicateValueLock(p)
require.Truef(t, locked, "value lock must hold regardless of case: %q", p)
require.Equal(t, testTrust, marker)
}
}

// TestRegisterReservedNamespaceRequiresTrustMarker confirms the invariant is
// enforced at registration (init time): ValueLocked without a TrustMarker would
// make the predicate unwritable by everyone, so it panics rather than failing
// silently at mutation time.
func TestRegisterReservedNamespaceRequiresTrustMarker(t *testing.T) {
require.Panics(t, func() {
RegisterReservedNamespace(ReservedNamespace{
Predicates: []string{"dgraph.nomarker.cfg"},
ValueLocked: []string{"dgraph.nomarker.cfg"},
// TrustMarker intentionally left nil.
})
})
}