From e997efd01214c7c53602e943debd846b9a893f98 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Wed, 17 Jun 2026 14:51:51 -0400 Subject: [PATCH 1/3] x,edgraph,worker: add a reserved-namespace plugin registry Introduce x.RegisterReservedNamespace, letting a plugin claim ownership of a sub-namespace under the reserved `dgraph.` prefix so its predicates and types can be created at runtime via Alter even though they are not part of the pre-defined initial schema, and optionally value-lock a subset of those predicates to a trusted in-process writer. The three reserved-namespace enforcement points now consult the registry: parseSchemaFromAlterOperation allows a registered predicate/type through the reserved-but-not-pre-defined check (IsRegisteredReservedPredicate / IsRegisteredReservedType); worker.proposeAndWait allows a registered predicate to be mutated before its schema exists; and the mutation value guard rejects writes to a value-locked predicate unless the request context carries the namespace's TrustMarker (ReservedPredicateValueLock). With no registration the registry is empty and every check reports false, so a stock build keeps the pristine behavior: nothing under `dgraph.` may be created except the pre-defined names, and the GraphQL reserved-value path is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- edgraph/server.go | 59 +++++++++++++++----- worker/proposal.go | 6 +- x/keys.go | 103 +++++++++++++++++++++++++++++++++++ x/reserved_namespace_test.go | 64 ++++++++++++++++++++++ 4 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 x/reserved_namespace_test.go diff --git a/edgraph/server.go b/edgraph/server.go index 4e815587aa5..7e6bb529e72 100644 --- a/edgraph/server.go +++ b/edgraph/server.go @@ -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)) @@ -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)) @@ -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 } @@ -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 { @@ -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 @@ -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 @@ -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 } } @@ -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 diff --git a/worker/proposal.go b/worker/proposal.go index a7761bee175..2562b0bb929 100644 --- a/worker/proposal.go +++ b/worker/proposal.go @@ -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.", diff --git a/x/keys.go b/x/keys.go index 94112d07c03..2aca87abab3 100644 --- a/x/keys.go +++ b/x/keys.go @@ -12,6 +12,7 @@ import ( "math" "strconv" "strings" + "sync" "github.com/pkg/errors" @@ -644,6 +645,108 @@ 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 must be the exact (bare) predicate form, matching how + // IsOtherReservedPredicate is consulted on the mutation path. + 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) iff ValueLocked is non-empty. + TrustMarker any +} + +var ( + reservedNsMu sync.RWMutex + reservedNsPrefixes []string + reservedNsPredicates = map[string]struct{}{} + reservedNsTypes = map[string]struct{}{} + reservedNsValueLocked = map[string]any{} // 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) { + 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[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 in its exact (bare) form, like IsOtherReservedPredicate. +func ReservedPredicateValueLock(pred string) (marker any, locked bool) { + reservedNsMu.RLock() + defer reservedNsMu.RUnlock() + marker, locked = reservedNsValueLocked[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. diff --git a/x/reserved_namespace_test.go b/x/reserved_namespace_test.go new file mode 100644 index 00000000000..e50171fca01 --- /dev/null +++ b/x/reserved_namespace_test.go @@ -0,0 +1,64 @@ +/* + * 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) +} From 855e2eba56b46b5463b178351f3bd9f0f1ecd755 Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Thu, 18 Jun 2026 17:13:34 -0400 Subject: [PATCH 2/3] x/keys.go: case-fold reserved value-lock lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReservedNamespace ownership matches names case-insensitively (the predicate, type, and prefix lookups all ToLower), but the value lock stored and looked up names raw. A namespace that value-locked a name with any uppercase letter (e.g. dgraph.foo.Secret) left the lowercased variant dgraph.foo.secret owned but not locked, so a mutation to it skipped the trust-marker check. Every reserved name today is lowercase, so nothing breaks in practice, but the two halves disagreed on case. Lowercase on both register and lookup, matching the predicate/type loops. Deliberately no ParseAttr in the value-lock path: the mutation guard passes the bare predicate (no namespace separator), so ParseAttr would panic — which is why it mirrors IsOtherReservedPredicate. Co-Authored-By: Claude Opus 4.8 (1M context) --- x/keys.go | 18 +++++++++++------- x/reserved_namespace_test.go | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/x/keys.go b/x/keys.go index 2aca87abab3..d9d97835458 100644 --- a/x/keys.go +++ b/x/keys.go @@ -671,8 +671,8 @@ type ReservedNamespace struct { // 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 must be the exact (bare) predicate form, matching how - // IsOtherReservedPredicate is consulted on the mutation path. + // 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 @@ -685,7 +685,7 @@ var ( reservedNsPrefixes []string reservedNsPredicates = map[string]struct{}{} reservedNsTypes = map[string]struct{}{} - reservedNsValueLocked = map[string]any{} // bare predicate -> TrustMarker + reservedNsValueLocked = map[string]any{} // lowercased bare predicate -> TrustMarker ) // RegisterReservedNamespace records a plugin's ownership of names under the @@ -704,7 +704,7 @@ func RegisterReservedNamespace(ns ReservedNamespace) { reservedNsTypes[strings.ToLower(t)] = struct{}{} } for _, p := range ns.ValueLocked { - reservedNsValueLocked[p] = ns.TrustMarker + reservedNsValueLocked[strings.ToLower(p)] = ns.TrustMarker } } @@ -738,12 +738,16 @@ func IsRegisteredReservedType(typ string) bool { // 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 in its exact (bare) form, like IsOtherReservedPredicate. +// 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[pred] + marker, locked = reservedNsValueLocked[strings.ToLower(pred)] return marker, locked } diff --git a/x/reserved_namespace_test.go b/x/reserved_namespace_test.go index e50171fca01..77f35938499 100644 --- a/x/reserved_namespace_test.go +++ b/x/reserved_namespace_test.go @@ -62,3 +62,20 @@ func TestReservedNamespaceRejectsUnregistered(t *testing.T) { _, 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) + } +} From ff30800bfe7b282a50c5582a788f70b035f165ba Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Thu, 18 Jun 2026 17:22:27 -0400 Subject: [PATCH 3/3] x/keys.go: enforce TrustMarker presence for value-locked predicates RegisterReservedNamespace documented that TrustMarker is required when ValueLocked is non-empty but never checked it. A caller that set ValueLocked with a nil TrustMarker would make the predicate unwritable by everyone, including its owner: the guard's marker != nil test stays false, so the write is always rejected. It fails closed (safe), but surfaces only at mutation time, far from the registration that caused it. Registration runs in init(), so panic on the misconfiguration to catch it at startup instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- x/keys.go | 7 ++++++- x/reserved_namespace_test.go | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/x/keys.go b/x/keys.go index d9d97835458..754b30f6123 100644 --- a/x/keys.go +++ b/x/keys.go @@ -676,7 +676,8 @@ type ReservedNamespace struct { 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) iff ValueLocked is non-empty. + // ValueLocked predicates. Required (non-nil) when ValueLocked is non-empty; + // RegisterReservedNamespace panics otherwise. TrustMarker any } @@ -692,6 +693,10 @@ var ( // 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 != "" { diff --git a/x/reserved_namespace_test.go b/x/reserved_namespace_test.go index 77f35938499..22561cc0e0e 100644 --- a/x/reserved_namespace_test.go +++ b/x/reserved_namespace_test.go @@ -79,3 +79,17 @@ func TestReservedPredicateValueLockCaseInsensitive(t *testing.T) { 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. + }) + }) +}