mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 21:37:32 +00:00
feat: implement user schema management (#7416)
This PR adds the functionality to manage user schemas through the new user schema service. It includes the possibility to create a basic JSON schema and also provides a way on defining permissions (read, write) for owner and self context with an annotation. Further annotations for OIDC claims and SAML attribute mappings will follow. A guide on how to create a schema and assign permissions has been started. It will be extended though out the process of implementing the schema and users based on those. Note: This feature is in an early stage and therefore not enabled by default. To test it out, please enable the UserSchema feature flag on your instance / system though the feature service.
This commit is contained in:
120
internal/domain/schema/permission.go
Normal file
120
internal/domain/schema/permission.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v5"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed permission.schema.v1.json
|
||||
permissionJSON string
|
||||
|
||||
permissionSchema = jsonschema.MustCompileString(PermissionSchemaID, permissionJSON)
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionSchemaID = "urn:zitadel:schema:permission-schema:v1"
|
||||
PermissionProperty = "urn:zitadel:schema:permission"
|
||||
)
|
||||
|
||||
type role int32
|
||||
|
||||
const (
|
||||
roleUnspecified role = iota
|
||||
roleSelf
|
||||
roleOwner
|
||||
)
|
||||
|
||||
type permissionExtension struct {
|
||||
role role
|
||||
}
|
||||
|
||||
// Compile implements the [jsonschema.ExtCompiler] interface.
|
||||
// It parses the permission schema extension / annotation of the passed field.
|
||||
func (c permissionExtension) Compile(ctx jsonschema.CompilerContext, m map[string]interface{}) (_ jsonschema.ExtSchema, err error) {
|
||||
perm, ok := m[PermissionProperty]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
p, ok := perm.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "SCHEMA-WR5gs", "invalid permission")
|
||||
}
|
||||
perms := new(permissions)
|
||||
for key, value := range p {
|
||||
switch key {
|
||||
case "self":
|
||||
perms.self, err = mapPermission(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case "owner":
|
||||
perms.owner, err = mapPermission(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "SCHEMA-GFjio", "invalid permission role")
|
||||
}
|
||||
}
|
||||
return permissionExtensionConfig{c.role, perms}, nil
|
||||
}
|
||||
|
||||
type permissionExtensionConfig struct {
|
||||
role role
|
||||
permissions *permissions
|
||||
}
|
||||
|
||||
// Validate implements the [jsonschema.ExtSchema] interface.
|
||||
// It validates the fields of the json instance according to the permission schema.
|
||||
func (s permissionExtensionConfig) Validate(ctx jsonschema.ValidationContext, v interface{}) error {
|
||||
switch s.role {
|
||||
case roleSelf:
|
||||
if s.permissions.self == nil || !s.permissions.self.write {
|
||||
return ctx.Error("permission", "missing required permission")
|
||||
}
|
||||
return nil
|
||||
case roleOwner:
|
||||
if s.permissions.owner == nil || !s.permissions.owner.write {
|
||||
return ctx.Error("permission", "missing required permission")
|
||||
}
|
||||
return nil
|
||||
case roleUnspecified:
|
||||
fallthrough
|
||||
default:
|
||||
return ctx.Error("permission", "missing required permission")
|
||||
}
|
||||
}
|
||||
|
||||
func mapPermission(value any) (*permission, error) {
|
||||
p := new(permission)
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
for _, s := range v {
|
||||
switch s {
|
||||
case 'r':
|
||||
p.read = true
|
||||
case 'w':
|
||||
p.write = true
|
||||
default:
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "SCHEMA-EZ5zjh", "invalid permission pattern: `%s` in (%s)", string(s), v)
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
default:
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "SCHEMA-E5h31", "invalid permission type %T (%v)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
type permissions struct {
|
||||
self *permission
|
||||
owner *permission
|
||||
}
|
||||
|
||||
type permission struct {
|
||||
read bool
|
||||
write bool
|
||||
}
|
28
internal/domain/schema/permission.schema.v1.json
Normal file
28
internal/domain/schema/permission.schema.v1.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$id": "urn:zitadel:schema:permission-schema:v1",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$defs": {
|
||||
"urn:zitadel:schema:property-permission": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[rw]$"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"urn:zitadel:schema:permission": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"owner": {
|
||||
"$ref": "#/$defs/urn:zitadel:schema:property-permission"
|
||||
},
|
||||
"self": {
|
||||
"$ref": "#/$defs/urn:zitadel:schema:property-permission"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
253
internal/domain/schema/permission_test.go
Normal file
253
internal/domain/schema/permission_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func TestPermissionExtension(t *testing.T) {
|
||||
type args struct {
|
||||
role role
|
||||
schema string
|
||||
instance string
|
||||
}
|
||||
type want struct {
|
||||
compilationErr error
|
||||
validationErr bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want want
|
||||
}{
|
||||
{
|
||||
"invalid permission, compilation err",
|
||||
args{
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": "read"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
want{
|
||||
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-WR5gs", "invalid permission"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid permission string, compilation err",
|
||||
args{
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"self": "read"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
want{
|
||||
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-EZ5zjh", "invalid permission pattern: `e` in (read)"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid permission type, compilation err",
|
||||
args{
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"owner": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
want{
|
||||
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-E5h31", "invalid permission type bool (true)"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid role, compilation err",
|
||||
args{
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"IAM_OWNER": "rw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
want{
|
||||
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-GFjio", "invalid permission role"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid permission self, validation err",
|
||||
args{
|
||||
role: roleSelf,
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"owner": "rw",
|
||||
"self": "r"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
instance: `{ "name": "test"}`,
|
||||
},
|
||||
want{
|
||||
validationErr: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid permission owner, validation err",
|
||||
args{
|
||||
role: roleOwner,
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"owner": "r",
|
||||
"self": "r"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
instance: `{ "name": "test"}`,
|
||||
},
|
||||
want{
|
||||
validationErr: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"valid permission self, ok",
|
||||
args{
|
||||
role: roleSelf,
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"owner": "r",
|
||||
"self": "rw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
instance: `{ "name": "test"}`,
|
||||
},
|
||||
want{
|
||||
validationErr: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"valid permission owner, ok",
|
||||
args{
|
||||
role: roleOwner,
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"owner": "rw",
|
||||
"self": "r"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
instance: `{ "name": "test"}`,
|
||||
},
|
||||
want{
|
||||
validationErr: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"no role, validation err",
|
||||
args{
|
||||
role: roleUnspecified,
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"urn:zitadel:schema:permission": {
|
||||
"owner": "rw",
|
||||
"self": "rw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
instance: `{ "name": "test"}`,
|
||||
},
|
||||
want{
|
||||
validationErr: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"no permission required, ok",
|
||||
args{
|
||||
role: roleSelf,
|
||||
schema: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
instance: `{ "name": "test"}`,
|
||||
},
|
||||
want{
|
||||
validationErr: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
schema, err := NewSchema(tt.args.role, strings.NewReader(tt.args.schema))
|
||||
require.ErrorIs(t, err, tt.want.compilationErr)
|
||||
if tt.want.compilationErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var v interface{}
|
||||
err = json.Unmarshal([]byte(tt.args.instance), &v)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = schema.Validate(v)
|
||||
if tt.want.validationErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
41
internal/domain/schema/schema.go
Normal file
41
internal/domain/schema/schema.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v5"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed zitadel.schema.v1.json
|
||||
zitadelJSON string
|
||||
)
|
||||
|
||||
const (
|
||||
MetaSchemaID = "urn:zitadel:schema:v1"
|
||||
)
|
||||
|
||||
func NewSchema(role role, r io.Reader) (*jsonschema.Schema, error) {
|
||||
c := jsonschema.NewCompiler()
|
||||
if err := c.AddResource(PermissionSchemaID, strings.NewReader(permissionJSON)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.AddResource(MetaSchemaID, strings.NewReader(zitadelJSON)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.RegisterExtension(PermissionSchemaID, permissionSchema, permissionExtension{
|
||||
role,
|
||||
})
|
||||
if err := c.AddResource("schema.json", r); err != nil {
|
||||
return nil, zerrors.ThrowInvalidArgument(err, "COMMA-Frh42", "Errors.UserSchema.Schema.Invalid")
|
||||
}
|
||||
schema, err := c.Compile("schema.json")
|
||||
if err != nil {
|
||||
return nil, zerrors.ThrowInvalidArgument(err, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid")
|
||||
}
|
||||
return schema, nil
|
||||
}
|
13
internal/domain/schema/zitadel.schema.v1.json
Normal file
13
internal/domain/schema/zitadel.schema.v1.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$id": "urn:zitadel:schema:v1",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "https://json-schema.org/draft/2020-12/schema"
|
||||
},
|
||||
{
|
||||
"$ref": "urn:zitadel:schema:permission-schema:v1"
|
||||
}
|
||||
]
|
||||
}
|
26
internal/domain/user_schema.go
Normal file
26
internal/domain/user_schema.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package domain
|
||||
|
||||
type UserSchemaState int32
|
||||
|
||||
const (
|
||||
UserSchemaStateUnspecified UserSchemaState = iota
|
||||
UserSchemaStateActive
|
||||
UserSchemaStateInactive
|
||||
UserSchemaStateDeleted
|
||||
userSchemaStateCount
|
||||
)
|
||||
|
||||
type AuthenticatorType int32
|
||||
|
||||
const (
|
||||
AuthenticatorTypeUnspecified AuthenticatorType = iota
|
||||
AuthenticatorTypeUsername
|
||||
AuthenticatorTypePassword
|
||||
AuthenticatorTypeWebAuthN
|
||||
AuthenticatorTypeTOTP
|
||||
AuthenticatorTypeOTPEmail
|
||||
AuthenticatorTypeOTPSMS
|
||||
AuthenticatorTypeAuthenticationKey
|
||||
AuthenticatorTypeIdentityProvider
|
||||
authenticatorTypeCount
|
||||
)
|
Reference in New Issue
Block a user