Skip to content

Commit 302250e

Browse files
authored
Feat/enforced restrictions (#376)
2 parents acc5905 + 591c403 commit 302250e

File tree

17 files changed

+949
-1105
lines changed

17 files changed

+949
-1105
lines changed

config/example-config.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,4 +294,34 @@ providers:
294294
request_parameter: "resource"
295295
# Defines how multiple audience values in a request are handled;
296296
space_separate_auds: false
297+
# Settings related to restrictions that should be enforced for different user groups depending on an OP attribute
298+
enforced_restrictions:
299+
# A mapping for claim sources: Mapping between an url and claim name; defines how the claim value is obtained
300+
# on which the decision which restriction template is enforced is based on
301+
# The key should be an url, that behaves in line with an OP's userinfo endpoint
302+
# The special keys 'issuer', 'op', 'default', and 'userinfo' can be used;
303+
# if one of these keys is used, the claim name is looked up in the
304+
# id token, access token, and userinfo endpoint
305+
claim_sources:
306+
#issuer: "eduperson_entitlements"
307+
# If true, indicates that access should be completely forbidden if no value from 'mapping' matches
308+
#forbid_on_default: false
309+
# The default restriction template that will be enforced for all users where no value from 'mapping' matches;
310+
# only used if 'forbid_on_default=false'; the restriction template must be configured on the server or be
311+
# provided as json (but as a string)
312+
# The help_html content is displayed if forbid_on_default=true and a user tries to log in and does not have
313+
# access. The html should contain information about why the user does not have access and what to do in order
314+
# to get access. The html can also be given in the help_html_file. This should only be a html snippet, no
315+
# complete html page.
316+
#help_html:
317+
#help_html_file:
318+
#default_template: web-default
319+
# A mapping between claim values and the enforced restriction template; the templates must be configured on the
320+
# server or be provided as json (but as a string); an entry matches if the attribute is a string array and the
321+
# mapping key is included in the array or the attribute is a single value and equals the mapping key;
322+
# the mapping MUST be given in order such that the highest privileged template is listed first
323+
mapping:
324+
#admin: ""
325+
#urn:geant:mytoken:advanced: "advanced"
326+
#urn:geant:mytoken:medium: "medium"
297327

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ require (
2525
github.com/lestrrat-go/jwx v1.2.30
2626
github.com/oidc-mytoken/api v0.11.2-0.20240426092102-fa4d583a79ad
2727
github.com/oidc-mytoken/lib v0.7.1
28-
github.com/oidc-mytoken/utils v0.1.3-0.20230731143919-ea5b78243e5d
28+
github.com/oidc-mytoken/utils v0.1.3-0.20240527155944-26103774a5aa
2929
github.com/olekukonko/tablewriter v0.0.5
3030
github.com/pires/go-proxyproto v0.8.0
3131
github.com/pkg/errors v0.9.1

go.sum

Lines changed: 2 additions & 625 deletions
Large diffs are not rendered by default.

internal/config/config.go

Lines changed: 159 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ type Config struct {
150150
Logging loggingConf `yaml:"logging"`
151151
ServiceDocumentation string `yaml:"service_documentation"`
152152
Features featuresConf `yaml:"features"`
153-
Providers []ProviderConf `yaml:"providers"`
153+
Providers []*ProviderConf `yaml:"providers"`
154154
ServiceOperator ServiceOperatorConf `yaml:"service_operator"`
155155
Caching cacheConf `yaml:"cache"`
156156
}
@@ -189,6 +189,9 @@ func (c *featuresConf) validate() error {
189189
if err := c.Notifications.validate(); err != nil {
190190
return err
191191
}
192+
if err := c.SSH.validate(); err != nil {
193+
return err
194+
}
192195
return nil
193196
}
194197

@@ -230,6 +233,27 @@ type sshConf struct {
230233
PrivateKeys []ssh.Signer `yaml:"-"`
231234
}
232235

236+
func (c *sshConf) validate() error {
237+
if !c.Enabled {
238+
return nil
239+
}
240+
if len(c.KeyFiles) == 0 {
241+
return errors.New("invalid config: ssh feature enabled, but no ssh private key set")
242+
}
243+
for _, pkf := range c.KeyFiles {
244+
pemBytes, err := os.ReadFile(pkf)
245+
if err != nil {
246+
return errors.Wrap(err, "reading ssh private key")
247+
}
248+
signer, err := ssh.ParsePrivateKey(pemBytes)
249+
if err != nil {
250+
return errors.Wrap(err, "parsing ssh private key")
251+
}
252+
c.PrivateKeys = append(c.PrivateKeys, signer)
253+
}
254+
return nil
255+
}
256+
233257
type serverProfilesConf struct {
234258
Enabled bool `yaml:"enabled"`
235259
Groups profileGroupsCredentials `yaml:"groups"`
@@ -422,14 +446,43 @@ type signingConf struct {
422446

423447
// ProviderConf holds information about a provider
424448
type ProviderConf struct {
425-
Issuer string `yaml:"issuer"`
426-
ClientID string `yaml:"client_id"`
427-
ClientSecret string `yaml:"client_secret"`
428-
Scopes []string `yaml:"scopes"`
429-
MytokensMaxLifetime int64 `yaml:"mytokens_max_lifetime"`
430-
Endpoints *oauth2x.Endpoints `yaml:"-"`
431-
Name string `yaml:"name"`
432-
Audience *model.AudienceConf `yaml:"audience"`
449+
Issuer string `yaml:"issuer"`
450+
ClientID string `yaml:"client_id"`
451+
ClientSecret string `yaml:"client_secret"`
452+
Scopes []string `yaml:"scopes"`
453+
MytokensMaxLifetime int64 `yaml:"mytokens_max_lifetime"`
454+
EnforcedRestrictions EnforcedRestrictionsConf `yaml:"enforced_restrictions"`
455+
Endpoints *oauth2x.Endpoints `yaml:"-"`
456+
Name string `yaml:"name"`
457+
Audience *model.AudienceConf `yaml:"audience"`
458+
}
459+
460+
// EnforcedRestrictionsConf is a type for holding configuration for enforced restrictions
461+
type EnforcedRestrictionsConf struct {
462+
Enabled bool `yaml:"-"`
463+
ClaimSources map[string]string `yaml:"claim_sources"`
464+
DefaultTemplate string `yaml:"default_template"`
465+
ForbidOnDefault bool `yaml:"forbid_on_default"`
466+
HelpHTMLText string `yaml:"help_html"`
467+
HelpHTMLFile string `yaml:"help_html_file"`
468+
Mapping map[string]string `yaml:"mapping"`
469+
}
470+
471+
func (c *EnforcedRestrictionsConf) validate() error {
472+
if len(c.ClaimSources) >= 1 {
473+
c.Enabled = true
474+
}
475+
if c.HelpHTMLFile != "" {
476+
content, err := os.ReadFile(c.HelpHTMLFile)
477+
if err != nil {
478+
return errors.Wrapf(
479+
err,
480+
"error reading enforced restrictions help html file '%s'", c.HelpHTMLFile,
481+
)
482+
}
483+
c.HelpHTMLText = string(content)
484+
}
485+
return nil
433486
}
434487

435488
// ServiceOperatorConf is type holding the configuration for the service operator of this mytoken instance
@@ -541,6 +594,28 @@ func validate() error {
541594
if conf == nil {
542595
return errors.New("config not set")
543596
}
597+
if err := validateIssuerURL(); err != nil {
598+
return err
599+
}
600+
if err := configureServerTLS(); err != nil {
601+
return err
602+
}
603+
if err := validateConfigSections(); err != nil {
604+
return err
605+
}
606+
if err := validateProviders(); err != nil {
607+
return err
608+
}
609+
if conf.Features.GuestMode.Enabled {
610+
addGuestModeProvider()
611+
}
612+
if err := validateSigningConfig(); err != nil {
613+
return err
614+
}
615+
return validateWebInterface()
616+
}
617+
618+
func validateIssuerURL() error {
544619
if conf.IssuerURL == "" {
545620
return errors.New("invalid config: issuer_url not set")
546621
}
@@ -553,94 +628,106 @@ func validate() error {
553628
return errors.Wrap(err, "invalid config: issuer_url not valid")
554629
}
555630
conf.Host = u.Hostname()
631+
return nil
632+
}
633+
634+
func configureServerTLS() error {
556635
if conf.Server.TLS.Enabled {
557636
if conf.Server.TLS.Key != "" && conf.Server.TLS.Cert != "" {
558637
conf.Server.Port = 443
559638
} else {
560639
conf.Server.TLS.Enabled = false
561640
}
562641
}
563-
if err = conf.Logging.validate(); err != nil {
564-
return err
565-
}
566-
if err = conf.ServiceOperator.validate(); err != nil {
642+
return nil
643+
}
644+
645+
func validateConfigSections() error {
646+
if err := conf.Logging.validate(); err != nil {
567647
return err
568648
}
569-
if err = conf.Features.validate(); err != nil {
649+
650+
if err := conf.ServiceOperator.validate(); err != nil {
570651
return err
571652
}
572-
if len(conf.Providers) <= 0 {
653+
654+
return conf.Features.validate()
655+
}
656+
657+
func validateProviders() error {
658+
if len(conf.Providers) == 0 {
573659
return errors.New("invalid config: providers must have at least one entry")
574660
}
575661
for i, p := range conf.Providers {
576-
if p.Issuer == "" {
577-
return errors.Errorf("invalid config: provider.issuer not set (Index %d)", i)
578-
}
579-
oc, err := oauth2x.NewConfig(context.Get(), p.Issuer)
580-
if err != nil {
581-
return errors.Errorf("error '%s' for provider.issuer '%s' (Index %d)", err, p.Issuer, i)
582-
}
583-
// Endpoints only returns an error if it does discovery but this was already done in NewConfig, so we can ignore
584-
// the error value
585-
p.Endpoints, _ = oc.Endpoints()
586-
if p.ClientID == "" {
587-
return errors.Errorf("invalid config: provider.clientid not set (Index %d)", i)
588-
}
589-
if p.ClientSecret == "" {
590-
return errors.Errorf("invalid config: provider.clientsecret not set (Index %d)", i)
591-
}
592-
if len(p.Scopes) <= 0 {
593-
return errors.Errorf("invalid config: provider.scopes not set (Index %d)", i)
594-
}
595-
if p.Audience == nil {
596-
p.Audience = &model.AudienceConf{RFC8707: true}
597-
}
598-
if p.Audience.RFC8707 {
599-
p.Audience.RequestParameter = model.AudienceParameterResource
600-
p.Audience.SpaceSeparateAuds = false
601-
} else if p.Audience.RequestParameter == "" {
602-
p.Audience.RequestParameter = model.AudienceParameterResource
662+
if err := validateProvider(p, i); err != nil {
663+
return err
603664
}
604665
conf.Providers[i] = p
605666
}
606-
if conf.Features.GuestMode.Enabled {
607-
iss := utils2.CombineURLPath(conf.IssuerURL, paths.GetCurrentAPIPaths().GuestModeOP)
608-
p := ProviderConf{
609-
Issuer: iss,
610-
Name: "Guest Mode",
611-
Scopes: []string{"openid"},
612-
Endpoints: &oauth2x.Endpoints{
613-
Authorization: utils2.CombineURLPath(iss, "auth"),
614-
Token: utils2.CombineURLPath(iss, "token"),
615-
},
616-
}
617-
conf.Providers = append(conf.Providers, p)
667+
return nil
668+
}
669+
670+
func validateProvider(p *ProviderConf, i int) error {
671+
if p.Issuer == "" {
672+
return errors.Errorf("invalid config: provider.issuer not set (Index %d)", i)
618673
}
619-
if conf.IssuerURL == "" {
620-
return errors.New("invalid config: issuer_url not set")
674+
if err := p.EnforcedRestrictions.validate(); err != nil {
675+
return err
676+
}
677+
oc, err := oauth2x.NewConfig(context.Get(), p.Issuer)
678+
if err != nil {
679+
return errors.Errorf("error '%s' for provider.issuer '%s' (Index %d)", err, p.Issuer, i)
621680
}
681+
p.Endpoints, err = oc.Endpoints()
682+
if err != nil {
683+
return errors.Errorf("error '%s' for provider.issuer '%s' (Index %d)", err, p.Issuer, i)
684+
}
685+
if p.ClientID == "" {
686+
return errors.Errorf("invalid config: provider.clientid not set (Index %d)", i)
687+
}
688+
if p.ClientSecret == "" {
689+
return errors.Errorf("invalid config: provider.clientsecret not set (Index %d)", i)
690+
}
691+
if len(p.Scopes) == 0 {
692+
return errors.Errorf("invalid config: provider.scopes not set (Index %d)", i)
693+
}
694+
if p.Audience == nil {
695+
p.Audience = &model.AudienceConf{RFC8707: true}
696+
}
697+
if p.Audience.RFC8707 {
698+
p.Audience.RequestParameter = model.AudienceParameterResource
699+
p.Audience.SpaceSeparateAuds = false
700+
} else if p.Audience.RequestParameter == "" {
701+
p.Audience.RequestParameter = model.AudienceParameterResource
702+
}
703+
return nil
704+
}
705+
706+
func addGuestModeProvider() {
707+
iss := utils2.CombineURLPath(conf.IssuerURL, paths.GetCurrentAPIPaths().GuestModeOP)
708+
p := &ProviderConf{
709+
Issuer: iss,
710+
Name: "Guest Mode",
711+
Scopes: []string{"openid"},
712+
Endpoints: &oauth2x.Endpoints{
713+
Authorization: utils2.CombineURLPath(iss, "auth"),
714+
Token: utils2.CombineURLPath(iss, "token"),
715+
},
716+
}
717+
conf.Providers = append(conf.Providers, p)
718+
}
719+
720+
func validateSigningConfig() error {
622721
if conf.Signing.Mytoken.KeyFile == "" {
623722
return errors.New("invalid config: signing keyfile not set")
624723
}
625724
if conf.Signing.Mytoken.Alg == "" {
626725
return errors.New("invalid config: token signing alg not set")
627726
}
628-
if conf.Features.SSH.Enabled {
629-
if len(conf.Features.SSH.KeyFiles) == 0 {
630-
return errors.New("invalid config: ssh feature enabled, but no ssh private key set")
631-
}
632-
for _, pkf := range conf.Features.SSH.KeyFiles {
633-
pemBytes, err := os.ReadFile(pkf)
634-
if err != nil {
635-
return errors.Wrap(err, "reading ssh private key")
636-
}
637-
signer, err := ssh.ParsePrivateKey(pemBytes)
638-
if err != nil {
639-
return errors.Wrap(err, "parsing ssh private key")
640-
}
641-
conf.Features.SSH.PrivateKeys = append(conf.Features.SSH.PrivateKeys, signer)
642-
}
643-
}
727+
return nil
728+
}
729+
730+
func validateWebInterface() error {
644731
if !conf.Features.TokenInfo.Introspect.Enabled && conf.Features.WebInterface.Enabled {
645732
return errors.New("web interface requires tokeninfo.introspect to be enabled")
646733
}

0 commit comments

Comments
 (0)