minimal webfinger (#5373)

* initial webfinger stub

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* add webfinger to proxy, return current host

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* some cleanup

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* allow passing multiple rel params

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* introduce interfaces

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* parse oidc auth token

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* add templating, drop chain, use map of relation providers

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* fix ocis url yaml

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* fix typos

Co-authored-by: Dominik Schmidt <dschmidt@owncloud.com>

* switch to userinfo claims

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* readme cleanup

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* add TODO.md with ideas

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* replace subject on authenticated request responses

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* Apply suggestions from code review

Co-authored-by: Martin <github@diemattels.at>

* markdown lint

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* return a 401 when bearer token expired, some more docs

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* Apply suggestions from code review

Co-authored-by: Martin <github@diemattels.at>

* fix docs

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* clarify env var

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* extract handler func

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* use correct service in reflex.conf

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* test relations

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>

* Update services/webfinger/pkg/config/config.go

---------

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
Co-authored-by: Dominik Schmidt <dschmidt@owncloud.com>
Co-authored-by: Martin <github@diemattels.at>
This commit is contained in:
Jörn Friedrich Dreyer
2023-02-13 11:05:20 +01:00
committed by GitHub
co-authored by Dominik Schmidt Martin
parent 139cf79f61
commit 2c98d3246c
46 changed files with 2072 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
package command
import (
"fmt"
"net/http"
"github.com/owncloud/ocis/v2/ocis-pkg/config/configlog"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config/parser"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/logging"
"github.com/urfave/cli/v2"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "health",
Usage: "check health status",
Category: "info",
Before: func(c *cli.Context) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
Action: func(c *cli.Context) error {
logger := logging.Configure(cfg.Service.Name, cfg.Log)
resp, err := http.Get(
fmt.Sprintf(
"http://%s/healthz",
cfg.Debug.Addr,
),
)
if err != nil {
logger.Fatal().
Err(err).
Msg("Failed to request health check")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Fatal().
Int("code", resp.StatusCode).
Msg("Health seems to be in bad state")
}
logger.Debug().
Int("code", resp.StatusCode).
Msg("Health got a good state")
return nil
},
}
}
+59
View File
@@ -0,0 +1,59 @@
package command
import (
"context"
"os"
"github.com/owncloud/ocis/v2/ocis-pkg/clihelper"
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/thejerf/suture/v4"
"github.com/urfave/cli/v2"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) cli.Commands {
return []*cli.Command{
// start this service
Server(cfg),
// interaction with this service
// infos about this service
Health(cfg),
Version(cfg),
}
}
// Execute is the entry point for the ocis webfinger command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cli.App{
Name: "webfinger",
Usage: "Serve webfinger API for oCIS",
Commands: GetCommands(cfg),
})
return app.Run(os.Args)
}
// SutureService allows for the webdav command to be embedded and supervised by a suture supervisor tree.
type SutureService struct {
cfg *config.Config
}
// NewSutureService creates a new webdav.SutureService
func NewSutureService(cfg *ociscfg.Config) suture.Service {
cfg.Webfinger.Commons = cfg.Commons
return SutureService{
cfg: cfg.Webfinger,
}
}
func (s SutureService) Serve(ctx context.Context) error {
s.cfg.Context = ctx
if err := Execute(s.cfg); err != nil {
return err
}
return nil
}
+142
View File
@@ -0,0 +1,142 @@
package command
import (
"context"
"fmt"
"github.com/oklog/run"
"github.com/owncloud/ocis/v2/ocis-pkg/config/configlog"
"github.com/owncloud/ocis/v2/ocis-pkg/version"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config/parser"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/logging"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/metrics"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/relations"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/server/debug"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/server/http"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/service/v0"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/tracing"
"github.com/urfave/cli/v2"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "server",
Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
Category: "server",
Before: func(c *cli.Context) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
Action: func(c *cli.Context) error {
logger := logging.Configure(cfg.Service.Name, cfg.Log)
err := tracing.Configure(cfg)
if err != nil {
return err
}
var (
gr = run.Group{}
ctx, cancel = func() (context.Context, context.CancelFunc) {
if cfg.Context == nil {
return context.WithCancel(context.Background())
}
return context.WithCancel(cfg.Context)
}()
metrics = metrics.New(metrics.Logger(logger))
)
defer cancel()
metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1)
{
relationProviders, err := getRelationProviders(cfg)
if err != nil {
logger.Error().Err(err).Msg("relation providier init")
return err
}
svc, err := service.New(
service.Logger(logger),
service.Config(cfg),
service.WithRelationProviders(relationProviders),
)
if err != nil {
logger.Error().Err(err).Msg("handler init")
return err
}
svc = service.NewInstrument(svc, metrics)
svc = service.NewLogging(svc, logger) // this logs service specific data
svc = service.NewTracing(svc)
server, err := http.Server(
http.Logger(logger),
http.Context(ctx),
http.Config(cfg),
http.Service(svc),
)
if err != nil {
logger.Info().
Err(err).
Str("transport", "http").
Msg("Failed to initialize server")
return err
}
gr.Add(func() error {
return server.Run()
}, func(err error) {
logger.Error().
Err(err).
Str("transport", "http").
Msg("Shutting down server")
cancel()
})
}
{
server, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(server.ListenAndServe, func(err error) {
logger.Error().Err(err)
_ = server.Shutdown(ctx)
cancel()
})
}
return gr.Run()
},
}
}
func getRelationProviders(cfg *config.Config) (map[string]service.RelationProvider, error) {
rels := map[string]service.RelationProvider{}
for _, relationURI := range cfg.Relations {
switch relationURI {
case relations.OpenIDConnectRel:
rels[relationURI] = relations.OpenIDDiscovery(cfg.IDP)
case relations.OwnCloudInstanceRel:
var err error
rels[relationURI], err = relations.OwnCloudInstance(cfg.Instances, cfg.OcisURL)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown relation '%s'", relationURI)
}
}
return rels, nil
}
+50
View File
@@ -0,0 +1,50 @@
package command
import (
"fmt"
"os"
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
"github.com/owncloud/ocis/v2/ocis-pkg/version"
tw "github.com/olekukonko/tablewriter"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/urfave/cli/v2"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "version",
Usage: "print the version of this binary and the running service instances",
Category: "info",
Action: func(c *cli.Context) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Compiled: %s\n", version.Compiled())
fmt.Println("")
reg := registry.GetRegistry()
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
if err != nil {
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
return err
}
if len(services) == 0 {
fmt.Println("No running " + cfg.Service.Name + " service found.")
return nil
}
table := tw.NewWriter(os.Stdout)
table.SetHeader([]string{"Version", "Address", "Id"})
table.SetAutoFormatHeaders(false)
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"context"
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
Tracing *Tracing `yaml:"tracing"`
Log *Log `yaml:"log"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
Instances []Instance `yaml:"instances"`
Relations []string `yaml:"relations" env:"WEBFINGER_RELATIONS" desc:"A comma-separated list of relation URIs or registered relation types to add to webfinger responses."`
IDP string `yaml:"idp" env:"OCIS_URL;OCIS_OIDC_ISSUER;WEBFINGER_OIDC_ISSUER" desc:"The identity provider href for the openid-discovery relation."`
OcisURL string `yaml:"ocis_url" env:"OCIS_URL;WEBFINGER_OWNCLOUD_SERVER_INSTANCE_URL" desc:"The URL for the legacy ownCloud server instance relation (not to be confused with the product ownCloud Server). It defaults to the OCIS_URL but can be overridden to support some reverse proxy corner cases. To shard the deployment, multiple instances can be configured in the configuration file."`
Context context.Context `yaml:"-"`
}
// Instance to use with a matching rule and titles
type Instance struct {
Claim string `yaml:"claim"`
Regex string `yaml:"regex"`
Href string `yaml:"href"`
Titles map[string]string `yaml:"titles"`
Break bool `yaml:"break"`
}
+9
View File
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"WEBFINGER_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed."`
Token string `yaml:"token" env:"WEBFINGER_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint."`
Pprof bool `yaml:"pprof" env:"WEBFINGER_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling."`
Zpages bool `yaml:"zpages" env:"WEBFINGER_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."`
}
@@ -0,0 +1,85 @@
package defaults
import (
"strings"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/relations"
)
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:0", // :0 to pick any free local port
Token: "",
Pprof: false,
Zpages: false,
},
HTTP: config.HTTP{
Addr: "127.0.0.1:0", // :0 to pick any free local port
Root: "/",
Namespace: "com.owncloud.web",
CORS: config.CORS{
AllowedOrigins: []string{"*"},
},
},
Service: config.Service{
Name: "webfinger",
},
Relations: []string{relations.OpenIDConnectRel, relations.OwnCloudInstanceRel},
Instances: []config.Instance{
{
Claim: "sub",
Regex: ".+",
Href: "{{.OCIS_URL}}",
Titles: map[string]string{
"en": "oCIS Instance",
},
},
},
}
}
func EnsureDefaults(cfg *config.Config) {
// provide with defaults for shared logging, since we need a valid destination address for "envdecode".
if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil {
cfg.Log = &config.Log{
Level: cfg.Commons.Log.Level,
Pretty: cfg.Commons.Log.Pretty,
Color: cfg.Commons.Log.Color,
File: cfg.Commons.Log.File,
}
} else if cfg.Log == nil {
cfg.Log = &config.Log{}
}
// provide with defaults for shared tracing, since we need a valid destination address for "envdecode".
if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil {
cfg.Tracing = &config.Tracing{
Enabled: cfg.Commons.Tracing.Enabled,
Type: cfg.Commons.Tracing.Type,
Endpoint: cfg.Commons.Tracing.Endpoint,
Collector: cfg.Commons.Tracing.Collector,
}
} else if cfg.Tracing == nil {
cfg.Tracing = &config.Tracing{}
}
if cfg.Commons != nil {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
}
func Sanitize(cfg *config.Config) {
// sanitize config
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
}
+20
View File
@@ -0,0 +1,20 @@
package config
import "github.com/owncloud/ocis/v2/ocis-pkg/shared"
// CORS defines the available cors configuration.
type CORS struct {
AllowedOrigins []string `yaml:"allow_origins" env:"OCIS_CORS_ALLOW_ORIGINS;WEBFINGER_CORS_ALLOW_ORIGINS" desc:"A comma-separated list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin"`
AllowedMethods []string `yaml:"allow_methods" env:"OCIS_CORS_ALLOW_METHODS;WEBFINGER_CORS_ALLOW_METHODS" desc:"A comma-separated list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method"`
AllowedHeaders []string `yaml:"allow_headers" env:"OCIS_CORS_ALLOW_HEADERS;WEBFINGER_CORS_ALLOW_HEADERS" desc:"A comma-separated list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers."`
AllowCredentials bool `yaml:"allow_credentials" env:"OCIS_CORS_ALLOW_CREDENTIALS;WEBFINGER_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials."`
}
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"WEBFINGER_HTTP_ADDR" desc:"The bind address of the HTTP service."`
Namespace string `yaml:"-"`
Root string `yaml:"root" env:"WEBFINGER_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service."`
CORS CORS `yaml:"cors"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
}
+9
View File
@@ -0,0 +1,9 @@
package config
// Log defines the available log configuration.
type Log struct {
Level string `mapstructure:"level" env:"OCIS_LOG_LEVEL;WEBFINGER_LOG_LEVEL" desc:"The log level. Valid values are: \"panic\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\"."`
Pretty bool `mapstructure:"pretty" env:"OCIS_LOG_PRETTY;WEBFINGER_LOG_PRETTY" desc:"Activates pretty log output."`
Color bool `mapstructure:"color" env:"OCIS_LOG_COLOR;WEBFINGER_LOG_COLOR" desc:"Activates colorized log output."`
File string `mapstructure:"file" env:"OCIS_LOG_FILE;WEBFINGER_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."`
}
@@ -0,0 +1,37 @@
package parser
import (
"errors"
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config/defaults"
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
_, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
func Validate(cfg *config.Config) error {
return nil
}
+6
View File
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
+9
View File
@@ -0,0 +1,9 @@
package config
// Tracing defines the available tracing configuration.
type Tracing struct {
Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;WEBFINGER_TRACING_ENABLED" desc:"Activates tracing."`
Type string `yaml:"type" env:"OCIS_TRACING_TYPE;WEBFINGER_TRACING_TYPE" desc:"The type of tracing. Defaults to \"\", which is the same as \"jaeger\". Allowed tracing types are \"jaeger\" and \"\" as of now."`
Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;WEBFINGER_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent."`
Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;WEBFINGER_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset."`
}
+17
View File
@@ -0,0 +1,17 @@
package logging
import (
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
)
// LoggerFromConfig initializes a service-specific logger instance.
func Configure(name string, cfg *config.Log) log.Logger {
return log.NewLogger(
log.Name(name),
log.Level(cfg.Level),
log.Pretty(cfg.Pretty),
log.Color(cfg.Color),
log.File(cfg.File),
)
}
+81
View File
@@ -0,0 +1,81 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "ocis"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "webfinger"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
BuildInfo *prometheus.GaugeVec
Counter *prometheus.CounterVec
Latency *prometheus.SummaryVec
Duration *prometheus.HistogramVec
}
// New initializes the available metrics.
func New(opts ...Option) *Metrics {
options := newOptions(opts...)
m := &Metrics{
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build information",
}, []string{"version"}),
Counter: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "webfinger_total",
Help: "How many webfinger requests processed",
}, []string{}),
Latency: prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "webfinger_latency_microseconds",
Help: "Webfinger request latencies in microseconds",
}, []string{}),
Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "webfinger_duration_seconds",
Help: "Webfinger request time in seconds",
}, []string{}),
}
if err := prometheus.Register(m.BuildInfo); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "BuildInfo").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Counter); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "counter").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Latency); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "latency").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Duration); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "duration").
Msg("Failed to register prometheus metric")
}
return m
}
+31
View File
@@ -0,0 +1,31 @@
package metrics
import (
"github.com/owncloud/ocis/v2/ocis-pkg/log"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
@@ -0,0 +1,33 @@
package relations
import (
"context"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/service/v0"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
)
const (
OpenIDConnectRel = "http://openid.net/specs/connect/1.0/issuer"
)
type openIDDiscovery struct {
Href string
}
// OpenIDDiscovery adds the Openid Connect issuer relation
func OpenIDDiscovery(href string) service.RelationProvider {
return &openIDDiscovery{
Href: href,
}
}
func (l *openIDDiscovery) Add(ctx context.Context, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}
jrd.Links = append(jrd.Links, webfinger.Link{
Rel: OpenIDConnectRel,
Href: l.Href,
})
}
@@ -0,0 +1,26 @@
package relations
import (
"context"
"testing"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
)
func TestOpenidDiscovery(t *testing.T) {
provider := OpenIDDiscovery("http://issuer.url")
jrd := webfinger.JSONResourceDescriptor{}
provider.Add(context.Background(), &jrd)
if len(jrd.Links) != 1 {
t.Errorf("provider returned wrong number of links: %v, expected 1", len(jrd.Links))
}
if jrd.Links[0].Href != "http://issuer.url" {
t.Errorf("provider returned wrong issuer link href: %v, expected %v", jrd.Links[0].Href, "http://issuer.url")
}
if jrd.Links[0].Rel != "http://openid.net/specs/connect/1.0/issuer" {
t.Errorf("provider returned wrong openid connect rel: %v, expected %v", jrd.Links[0].Href, OpenIDConnectRel)
}
}
@@ -0,0 +1,87 @@
package relations
import (
"context"
"net/url"
"regexp"
"strings"
"text/template"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/service/v0"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
)
const (
OwnCloudInstanceRel = "http://webfinger.owncloud/rel/server-instance"
)
type compiledInstance struct {
config.Instance
compiledRegex *regexp.Regexp
hrefTemplate *template.Template
}
type ownCloudInstance struct {
instances []compiledInstance
ocisURL string
instanceHost string
}
// OwnCloudInstance adds one or more ownCloud instance relations
func OwnCloudInstance(instances []config.Instance, ocisURL string) (service.RelationProvider, error) {
compiledInstances := make([]compiledInstance, 0, len(instances))
var err error
for _, instance := range instances {
compiled := compiledInstance{Instance: instance}
compiled.compiledRegex, err = regexp.Compile(instance.Regex)
if err != nil {
return nil, err
}
compiled.hrefTemplate, err = template.New(instance.Claim + ":" + instance.Regex + ":" + instance.Href).Parse(instance.Href)
if err != nil {
return nil, err
}
compiledInstances = append(compiledInstances, compiled)
}
u, err := url.Parse(ocisURL)
if err != nil {
return nil, err
}
return &ownCloudInstance{
instances: compiledInstances,
ocisURL: ocisURL,
instanceHost: u.Host + u.Path,
}, nil
}
func (l *ownCloudInstance) Add(ctx context.Context, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}
if claims := oidc.FromContext(ctx); claims != nil {
if value, ok := claims[oidc.PreferredUsername].(string); ok {
jrd.Subject = "acct:" + value + "@" + l.instanceHost
} else if value, ok := claims[oidc.Email].(string); ok {
jrd.Subject = "mailto:" + value
}
// allow referencing OCIS_URL in the template
claims["OCIS_URL"] = l.ocisURL
for _, instance := range l.instances {
if value, ok := claims[instance.Claim].(string); ok && instance.compiledRegex.MatchString(value) {
var tmplWriter strings.Builder
instance.hrefTemplate.Execute(&tmplWriter, claims)
jrd.Links = append(jrd.Links, webfinger.Link{
Rel: OwnCloudInstanceRel,
Href: tmplWriter.String(),
Titles: instance.Titles,
})
if instance.Break {
break
}
}
}
}
}
@@ -0,0 +1,65 @@
package relations
import (
"context"
"testing"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
)
func TestOwnCloudInstanceErr(t *testing.T) {
_, err := OwnCloudInstance([]config.Instance{}, "http://\n\rinvalid")
if err == nil {
t.Errorf("provider did not err on invalid url: %v", err)
}
_, err = OwnCloudInstance([]config.Instance{{Regex: "("}}, "http://docis.tld")
if err == nil {
t.Errorf("provider did not err on invalid regex: %v", err)
}
_, err = OwnCloudInstance([]config.Instance{{Href: "{{invalid}}ee"}}, "http://docis.tld")
if err == nil {
t.Errorf("provider did not err on invalid href template: %v", err)
}
}
func TestOwnCloudInstanceAddLink(t *testing.T) {
provider, err := OwnCloudInstance([]config.Instance{{
Claim: "customclaim",
Regex: ".+@.+\\..+",
Href: "https://{{.otherclaim}}.domain.tld",
Titles: map[string]string{
"foo": "bar",
},
Break: true,
}}, "http://docis.tld")
if err != nil {
t.Error(err)
}
ctx := context.Background()
ctx = oidc.NewContext(ctx, map[string]interface{}{
"customclaim": "some@fizz.buzz",
"otherclaim": "someone",
})
jrd := webfinger.JSONResourceDescriptor{}
provider.Add(ctx, &jrd)
if len(jrd.Links) != 1 {
t.Errorf("provider returned wrong number of links: %v, expected 1", len(jrd.Links))
}
if jrd.Links[0].Href != "https://someone.domain.tld" {
t.Errorf("provider returned wrong issuer link href: %v, expected %v", jrd.Links[0].Href, "https://someone.domain.tld")
}
if jrd.Links[0].Rel != OwnCloudInstanceRel {
t.Errorf("provider returned owncloud server instance rel: %v, expected %v", jrd.Links[0].Rel, OwnCloudInstanceRel)
}
if len(jrd.Links[0].Titles) != 1 {
t.Errorf("provider returned wrong number of titles: %v, expected 1", len(jrd.Links[0].Titles))
}
if jrd.Links[0].Titles["foo"] != "bar" {
t.Errorf("provider returned wrong title: %v, expected bar", len(jrd.Links[0].Titles["foo"]))
}
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,63 @@
package debug
import (
"io"
"net/http"
"github.com/owncloud/ocis/v2/ocis-pkg/service/debug"
"github.com/owncloud/ocis/v2/ocis-pkg/version"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Health(health(options.Config)),
debug.Ready(ready(options.Config)),
debug.CorsAllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
debug.CorsAllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
debug.CorsAllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
debug.CorsAllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
), nil
}
// health implements the health check.
func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
// TODO: check if services are up and running
_, err := io.WriteString(w, http.StatusText(http.StatusOK))
// io.WriteString should not fail but if it does we want to know.
if err != nil {
panic(err)
}
}
}
// ready implements the ready check.
func ready(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
// TODO: check if services are up and running
_, err := io.WriteString(w, http.StatusText(http.StatusOK))
// io.WriteString should not fail but if it does we want to know.
if err != nil {
panic(err)
}
}
}
@@ -0,0 +1,84 @@
package http
import (
"context"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
svc "github.com/owncloud/ocis/v2/services/webfinger/pkg/service/v0"
"github.com/urfave/cli/v2"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Name string
Namespace string
Logger log.Logger
Context context.Context
Config *config.Config
Flags []cli.Flag
Service svc.Service
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Name provides a name for the service.
func Name(val string) Option {
return func(o *Options) {
o.Name = val
}
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Flags provides a function to set the flags option.
func Flags(val []cli.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, val...)
}
}
// Namespace provides a function to set the namespace option.
func Namespace(val string) Option {
return func(o *Options) {
o.Namespace = val
}
}
// Service provides a function to set the service option.
func Service(val svc.Service) Option {
return func(o *Options) {
o.Service = val
}
}
@@ -0,0 +1,132 @@
package http
import (
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/owncloud/ocis/v2/ocis-pkg/cors"
"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
ohttp "github.com/owncloud/ocis/v2/ocis-pkg/service/http"
"github.com/owncloud/ocis/v2/ocis-pkg/version"
serviceErrors "github.com/owncloud/ocis/v2/services/webfinger/pkg/service/v0"
svc "github.com/owncloud/ocis/v2/services/webfinger/pkg/service/v0"
"github.com/pkg/errors"
"go-micro.dev/v4"
)
// Server initializes the http service and server.
func Server(opts ...Option) (ohttp.Service, error) {
options := newOptions(opts...)
service := options.Service
svc, err := ohttp.NewService(
ohttp.TLSConfig(options.Config.HTTP.TLS),
ohttp.Logger(options.Logger),
ohttp.Namespace(options.Config.HTTP.Namespace),
ohttp.Name(options.Config.Service.Name),
ohttp.Version(version.GetString()),
ohttp.Address(options.Config.HTTP.Addr),
ohttp.Context(options.Context),
ohttp.Flags(options.Flags...),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return ohttp.Service{}, err
}
mux := chi.NewMux()
mux.Use(chimiddleware.RealIP)
mux.Use(chimiddleware.RequestID)
mux.Use(middleware.TraceContext)
mux.Use(middleware.NoCache)
mux.Use(
middleware.Cors(
cors.Logger(options.Logger),
cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
))
mux.Use(middleware.Secure)
mux.Use(middleware.Version(
options.Name,
version.String,
))
mux.Use(middleware.OidcAuth(
middleware.WithLogger(options.Logger),
middleware.WithOidcIssuer(options.Config.IDP),
))
// this logs http request related data
mux.Use(middleware.Logger(
options.Logger,
))
mux.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Get("/.well-known/webfinger", WebfingerHandler(service))
})
err = micro.RegisterHandler(svc.Server(), mux)
if err != nil {
options.Logger.Fatal().Err(err).Msg("failed to register the handler")
}
svc.Init()
return svc, nil
}
func WebfingerHandler(service svc.Service) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// A WebFinger URI MUST contain a query component (see Section 3.4 of
// RFC 3986). The query component MUST contain a "resource" parameter
// and MAY contain one or more "rel" parameters.
resource := r.URL.Query().Get("resource")
queryTarget, err := url.Parse(resource)
if resource == "" || err != nil {
// If the "resource" parameter is absent or malformed, the WebFinger
// resource MUST indicate that the request is bad as per Section 10.4.1
// of RFC 2616.
render.Status(r, http.StatusBadRequest)
render.PlainText(w, r, "absent or malformed 'resource' parameter")
return
}
rels := make([]string, 0)
for k, v := range r.URL.Query() {
if k == "rel" {
rels = append(rels, v...)
}
}
jrd, err := service.Webfinger(ctx, queryTarget, rels)
if errors.Is(err, serviceErrors.ErrNotFound) {
// from https://www.rfc-editor.org/rfc/rfc7033#section-4.2
//
// If the "resource" parameter is a value for which the server has no
// information, the server MUST indicate that it was unable to match the
// request as per Section 10.4.5 of RFC 2616.
render.Status(r, http.StatusNotFound)
render.PlainText(w, r, err.Error())
return
}
if err != nil {
render.Status(r, http.StatusInternalServerError)
render.PlainText(w, r, err.Error())
return
}
w.Header().Set("Content-type", "application/jrd+json")
render.Status(r, http.StatusOK)
render.JSON(w, r, jrd)
}
}
@@ -0,0 +1,5 @@
package service
import "errors"
var ErrNotFound = errors.New("query target not found")
@@ -0,0 +1,39 @@
package service
import (
"context"
"net/url"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/metrics"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
"github.com/prometheus/client_golang/prometheus"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// Webfinger implements the Service interface.
func (i instrument) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error) {
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
us := v * 1000000
i.metrics.Latency.WithLabelValues().Observe(us)
i.metrics.Duration.WithLabelValues().Observe(v)
}))
defer timer.ObserveDuration()
i.metrics.Counter.WithLabelValues().Inc()
return i.next.Webfinger(ctx, queryTarget, rels)
}
@@ -0,0 +1,32 @@
package service
import (
"context"
"net/url"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
)
// NewLogging returns a service that logs messages.
func NewLogging(next Service, logger log.Logger) Service {
return logging{
next: next,
logger: logger,
}
}
type logging struct {
next Service
logger log.Logger
}
// Webfinger implements the Service interface.
func (l logging) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error) {
l.logger.Debug().
Str("query_target", queryTarget.String()).
Strs("rel", rels).
Msg("Webfinger")
return l.next.Webfinger(ctx, queryTarget, rels)
}
@@ -0,0 +1,47 @@
package service
import (
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
RelationProviders map[string]RelationProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
func WithRelationProviders(val map[string]RelationProvider) Option {
return func(o *Options) {
o.RelationProviders = val
}
}
@@ -0,0 +1,110 @@
package service
import (
"context"
"net/url"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
)
const (
OwnCloudInstanceRel = "http://webfinger.owncloud/rel/server-instance"
OpenIDConnectRel = "http://openid.net/specs/connect/1.0/issuer"
)
// Service defines the extension handlers.
type Service interface {
// Webfinger is the endpoint for retrieving various href relations.
//
// GET /.well-known/webfinger?
// resource=acct%3Acarol%40example.com&
// rel=http%3A%2F%2Fwebfinger.owncloud%rel%2Fserver-instance
// HTTP/1.1
// Host: example.com
//
// The server might respond like this:
//
// HTTP/1.1 200 OK
// Access-Control-Allow-Origin: *
// Content-Type: application/jrd+json
//
// {
// "subject" : "acct:carol@example.com",
// "links" :
// [
// {
// "rel" : "http://webfinger.owncloud/rel/server-instance",
// "href" : "https://instance.example.com",
// "titles": {
// "en": "Readable Instance Name"
// }
// },
// {
// "rel" : "http://webfinger.owncloud/rel/server-instance",
// "href" : "https://otherinstance.example.com",
// "titles": {
// "en": "Other Readable Instance Name"
// }
// }
// ]
// }
Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error)
}
type RelationProvider interface {
Add(ctx context.Context, jrd *webfinger.JSONResourceDescriptor)
}
// New returns a new instance of Service
func New(opts ...Option) (Service, error) {
options := newOptions(opts...)
// TODO use fallback implementations of InstanceIdLookup and InstanceLookup?
// The InstanceIdLookup may have to happen earlier?
return svc{
log: options.Logger,
config: options.Config,
relationProviders: options.RelationProviders,
}, nil
}
type svc struct {
config *config.Config
log log.Logger
relationProviders map[string]RelationProvider
}
// TODO implement different implementations:
// static one returning the href or a configureable domain
// regex one returning different instances based on the regex that matches
// claim one that reads a claim and then fetches the instance?
// that is actually two interfaces / steps:
// - one that determines the instances/schools id (read from claim, regex match)
// - one that looks up in instance by id (use template, read from json, read from ldap, read from graph)
// Webfinger implements the service interface
func (s svc) Webfinger(ctx context.Context, queryTarget *url.URL, rel []string) (webfinger.JSONResourceDescriptor, error) {
jrd := webfinger.JSONResourceDescriptor{
Subject: queryTarget.String(),
}
if len(rel) == 0 {
// add all configured relation providers
for _, relation := range s.relationProviders {
relation.Add(ctx, &jrd)
}
} else {
// only add requested relations
for _, r := range rel {
if relation, ok := s.relationProviders[r]; ok {
relation.Add(ctx, &jrd)
}
}
}
return jrd, nil
}
@@ -0,0 +1,33 @@
package service
import (
"context"
"net/url"
webfingertracing "github.com/owncloud/ocis/v2/services/webfinger/pkg/tracing"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/webfinger"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// NewTracing returns a service that instruments traces.
func NewTracing(next Service) Service {
return tracing{
next: next,
}
}
type tracing struct {
next Service
}
// Webfinger implements the Service interface.
func (t tracing) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string) (webfinger.JSONResourceDescriptor, error) {
ctx, span := webfingertracing.TraceProvider.Tracer("webfinger").Start(ctx, "Webfinger", trace.WithAttributes(
attribute.KeyValue{Key: "query_target", Value: attribute.StringValue(queryTarget.String())},
attribute.KeyValue{Key: "rels", Value: attribute.StringSliceValue(rels)},
))
defer span.End()
return t.next.Webfinger(ctx, queryTarget, rels)
}
+23
View File
@@ -0,0 +1,23 @@
package tracing
import (
pkgtrace "github.com/owncloud/ocis/v2/ocis-pkg/tracing"
"github.com/owncloud/ocis/v2/services/webfinger/pkg/config"
"go.opentelemetry.io/otel/trace"
)
var (
// TraceProvider is the global trace provider for the proxy service.
TraceProvider = trace.NewNoopTracerProvider()
)
func Configure(cfg *config.Config) error {
var err error
if cfg.Tracing.Enabled {
if TraceProvider, err = pkgtrace.GetTraceProvider(cfg.Tracing.Endpoint, cfg.Tracing.Collector, cfg.Service.Name, cfg.Tracing.Type); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,64 @@
package webfinger
// Link represents a link relation object as per https://www.rfc-editor.org/rfc/rfc7033#section-4.4.4
type Link struct {
// Rel is either a URI or a registered relation type (see RFC 5988)
//
// The "rel" member MUST be present in the link relation object.
Rel string `json:"rel"`
// Type indicates the media type of the target resource
//
// The "type" member is OPTIONAL in the link relation object.
Type string `json:"type,omitempty"`
// Href contains a URI pointing to the target resource.
//
// The "href" member is OPTIONAL in the link relation object.
Href string `json:"href,omitempty"`
// The "properties" object within the link relation object comprises
// zero or more name/value pairs whose names are URIs (referred to as
// "property identifiers") and whose values are strings or null.
//
// Properties are used to convey additional information about the link
// relation. As an example, consider this use of "properties":
//
// "properties" : { "http://webfinger.example/mail/port" : "993" }
//
// The "properties" member is OPTIONAL in the link relation object.
Properties map[string]string `json:"properties,omitempty"`
// Titles comprises zero or more name/value pairs whose
// names are a language tag or the string "und"
//
// Here is an example of the "titles" object:
//
// "titles" :
// {
// "en-us" : "The Magical World of Steve",
// "fr" : "Le Monde Magique de Steve"
// }
//
// The "titles" member is OPTIONAL in the link relation object.
Titles map[string]string `json:"titles,omitempty"`
}
// JSONResourceDescriptor represents a JSON Resource Descriptor (JRD) as per https://www.rfc-editor.org/rfc/rfc7033#section-4.4
type JSONResourceDescriptor struct {
// Subject is a URI that identifies the entity that the JRD describes
//
// The "subject" member SHOULD be present in the JRD.
Subject string `json:"subject,omitempty"`
// Aliases is an array of zero or more URI strings that identify the same
// entity as the "subject" URI.
//
// The "aliases" array is OPTIONAL in the JRD.
Aliases []string `json:"aliases,omitempty"`
// Properties is an object comprising zero or more name/value pairs whose
// names are URIs (referred to as "property identifiers") and whose
// values are strings or null.
//
// The "properties" member is OPTIONAL in the JRD.
Properties map[string]string `json:"properties,omitempty"`
// Links is an array of objects that contain link relation information
//
// The "links" array is OPTIONAL in the JRD.
Links []Link `json:"links,omitempty"`
}