Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add org domain whitelisting #289

Merged
merged 9 commits into from
Aug 13, 2023
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto ui
.DEFAULT_GOAL := build
PROTON_COMMIT := "be5fdf7f6ee27412fc45fef2778cf322109da399"
PROTON_COMMIT := "fba5bc5edb16a65200afe8b731ca8ca4d7f3f054"

ui:
@echo " > generating ui build"
Expand Down
16 changes: 14 additions & 2 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/raystack/frontier/core/audit"
"github.com/raystack/frontier/core/domain"

"github.com/raystack/frontier/core/serviceuser"

Expand Down Expand Up @@ -133,15 +134,22 @@ func StartServer(logger *log.Zap, cfg *config.Frontier) error {
}

// session service initialization and cleanup
if err := deps.SessionService.InitSessions(context.Background()); err != nil {
if err := deps.SessionService.InitSessions(ctx); err != nil {
logger.Warn("sessions database cleanup failed", "err", err)
}
defer func() {
logger.Debug("cleaning up cron jobs")
deps.SessionService.Close()
}()

if err := deps.AuthnService.InitFlows(context.Background()); err != nil {
if err := deps.DomainService.InitDomainVerification(ctx); err != nil {
logger.Warn("domains database cleanup failed", "err", err)
}
defer func() {
deps.DomainService.Close()
kushsharma marked this conversation as resolved.
Show resolved Hide resolved
}()

if err := deps.AuthnService.InitFlows(ctx); err != nil {
logger.Warn("flows database cleanup failed", "err", err)
}
defer func() {
Expand Down Expand Up @@ -254,6 +262,9 @@ func buildAPIDependencies(
organizationRepository := postgres.NewOrganizationRepository(dbc)
organizationService := organization.NewService(organizationRepository, relationService, userService, authnService)

domainRepository := postgres.NewDomainRepository(logger, dbc)
domainService := domain.NewService(logger, domainRepository, userService, organizationService)

projectRepository := postgres.NewProjectRepository(dbc)
projectService := project.NewService(projectRepository, relationService, userService)

Expand Down Expand Up @@ -308,6 +319,7 @@ func buildAPIDependencies(
InvitationService: invitationService,
ServiceUserService: serviceUserService,
AuditService: auditService,
DomainService: domainService,
}
return dependencies, nil
}
Expand Down
36 changes: 36 additions & 0 deletions core/domain/domain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package domain

import (
"context"
"time"
)

type Repository interface {
Create(ctx context.Context, domain Domain) (Domain, error)
Get(ctx context.Context, id string) (Domain, error)
Update(ctx context.Context, domain Domain) (Domain, error)
List(ctx context.Context, flt Filter) ([]Domain, error)
Delete(ctx context.Context, id string) error
DeleteExpiredDomainRequests(ctx context.Context) error
}

type Status string

func (s Status) String() string {
return string(s)
}

const (
Pending Status = "pending"
Verified Status = "verified"
)

type Domain struct {
ID string
Name string
OrgID string
Token string
State Status
UpdatedAt time.Time
CreatedAt time.Time
}
12 changes: 12 additions & 0 deletions core/domain/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package domain

import "errors"

var (
ErrNotExist = errors.New("org domain request does not exist")
ErrInvalidDomain = errors.New("invalid domain. No such host found")
ErrTXTrecordNotFound = errors.New("required TXT record not found for domain verification")
ErrDomainsMisMatch = errors.New("user domain does not match the organization domain")
ErrInvalidId = errors.New("invalid domain id")
ErrDuplicateKey = errors.New("domain name already exists for that organization")
)
7 changes: 7 additions & 0 deletions core/domain/filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package domain

type Filter struct {
OrgID string
State Status
Name string
}
228 changes: 228 additions & 0 deletions core/domain/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package domain

import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net"
"strings"
"time"

"github.com/raystack/salt/log"

"github.com/raystack/frontier/core/authenticate"
"github.com/raystack/frontier/core/organization"
"github.com/raystack/frontier/core/user"
"github.com/raystack/frontier/internal/bootstrap/schema"
"github.com/robfig/cron/v3"
)

type UserService interface {
GetByID(ctx context.Context, id string) (user.User, error)
}

type OrgService interface {
ListByUser(ctx context.Context, userID string) ([]organization.Organization, error)
AddMember(ctx context.Context, orgID, relationName string, principal authenticate.Principal) error
Get(ctx context.Context, id string) (organization.Organization, error)
}

type Service struct {
repository Repository
userService UserService
orgService OrgService
cron *cron.Cron
log log.Logger
}

const (
DNSChallenge = "_frontier-domain-verification=%s"
txtLength = 40
DefaultTokenExpiry = time.Hour * 24 * 7 // 7 days
refreshTime = "0 0 * * *" // Once a day at midnight (UTC)
)

func NewService(logger log.Logger, repository Repository, userService UserService, orgService OrgService) *Service {
return &Service{
repository: repository,
userService: userService,
orgService: orgService,
cron: cron.New(),
log: logger,
}
}

// Get an organization's whitelisted domain from the database
func (s Service) Get(ctx context.Context, id string) (Domain, error) {
return s.repository.Get(ctx, id)
}

// List all whitelisted domains for an organization (filter by verified boolean)
func (s Service) List(ctx context.Context, flt Filter) ([]Domain, error) {
return s.repository.List(ctx, flt)
}

// Remove an organization's whitelisted domain from the database
func (s Service) Delete(ctx context.Context, id string) error {
return s.repository.Delete(ctx, id)
}

// Creates a record for the domain in the database and returns the TXT record that needs to be added to the DNS for the domain verification
func (s Service) Create(ctx context.Context, domain Domain) (Domain, error) {
orgResp, err := s.orgService.Get(ctx, domain.OrgID)
if err != nil {
return Domain{}, err
}

txtRecord, err := generateRandomTXT()
if err != nil {
return Domain{}, err
}

domain.OrgID = orgResp.ID // in case the orgName is provided in the request, replace with the orgID
domain.Token = fmt.Sprintf(DNSChallenge, txtRecord)
var domainResp Domain
if domainResp, err = s.repository.Create(ctx, domain); err != nil {
return Domain{}, err
}

return domainResp, nil
}

// VerifyDomain checks if the TXT record for the domain matches the token generated by Frontier for the domain verification
func (s Service) VerifyDomain(ctx context.Context, id string) (Domain, error) {
domain, err := s.repository.Get(ctx, id)
if err != nil {
return Domain{}, ErrNotExist
}

txtRecords, err := net.LookupTXT(domain.Name)
if err != nil {
if strings.Contains(err.Error(), "no such host") {
return Domain{}, ErrInvalidDomain
}
return Domain{}, err
}

for _, txtRecord := range txtRecords {
if strings.TrimSpace(txtRecord) == strings.TrimSpace(domain.Token) {
domain.State = Verified
domain, err = s.repository.Update(ctx, domain)
if err != nil {
return Domain{}, err
}
return domain, nil
}
}

return domain, ErrTXTrecordNotFound
}

// Join an organization as a member if the user domain matches the org whitelisted domains
func (s Service) Join(ctx context.Context, orgID string, userId string) error {
orgResp, err := s.orgService.Get(ctx, orgID)
if err != nil {
return err
}

currUser, err := s.userService.GetByID(ctx, userId)
if err != nil {
return err
}

// check if user is already a member of the organization. if yes, do nothing and return nil
userOrgs, err := s.orgService.ListByUser(ctx, currUser.ID)
if err != nil {
return err
}

for _, org := range userOrgs {
if org.ID == orgResp.ID {
return nil
}
}

userDomain := extractDomainFromEmail(currUser.Email)
if userDomain == "" {
return user.ErrInvalidEmail
}

// check if user domain matches the org whitelisted domains
orgTrustedDomains, err := s.List(ctx, Filter{
OrgID: orgResp.ID,
State: Verified,
})
if err != nil {
return err
}

for _, dmn := range orgTrustedDomains {
if userDomain == dmn.Name {
if err = s.orgService.AddMember(ctx, orgResp.ID, schema.MemberRelationName, authenticate.Principal{
ID: currUser.ID,
Type: schema.UserPrincipal,
}); err != nil {
return err
}
return nil
}
}

return ErrDomainsMisMatch
}

func (s Service) ListOrgByDomain(ctx context.Context, email string) ([]string, error) {
domain := extractDomainFromEmail(email)
domains, err := s.repository.List(ctx, Filter{
Name: domain,
State: Verified,
})
if err != nil {
return nil, err
}

var orgIDs []string
for _, domain := range domains {
orgIDs = append(orgIDs, domain.OrgID)
}
return orgIDs, nil
}

// InitDomainVerification starts a cron job that runs once a day to delete expired domain requests which are still in pending state after 7 days of creation
func (s Service) InitDomainVerification(ctx context.Context) error {
_, err := s.cron.AddFunc(refreshTime, func() {
if err := s.repository.DeleteExpiredDomainRequests(ctx); err != nil {
s.log.Warn("error deleting expired domain requests", "err", err)
}
})
if err != nil {
return err
}
s.cron.Start()
return nil
}

func (s Service) Close() {
s.cron.Stop()
}

func generateRandomTXT() (string, error) {
randomBytes := make([]byte, txtLength)
_, err := rand.Read(randomBytes)
if err != nil {
return "", err
}

// Encode the random bytes in Base64
txtRecord := base64.StdEncoding.EncodeToString(randomBytes)
return txtRecord, nil
}

func extractDomainFromEmail(email string) string {
parts := strings.Split(email, "@")
if len(parts) == 2 {
return parts[1]
}
return ""
}
7 changes: 4 additions & 3 deletions docs/docs/apis/admin-service-create-permission.api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ api:
"namespace":
{
"type": "string",
"description": "The namespace of the permission.The namespace should be in service/resource format.<br/>*Example:*`app/guardian`",
"description": "The namespace of the permission. The namespace should be in service/resource format.<br/>*Example:*`compute/guardian`",
"title": "namespace should be in service/resource format",
},
"metadata":
Expand All @@ -289,7 +289,8 @@ api:
{
"type": "string",
"example": "compute.instance.get",
"description": "Permission path key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'.",
"description": "Permission path key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'. Namespace name cannot be `app` as it's reserved for core permissions.",
"title": "key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'.\nUse this instead of using name and namespace fields",
},
},
},
Expand Down Expand Up @@ -400,7 +401,7 @@ import TabItem from "@theme/TabItem";

Creates a permission. It can be used to grant permissions to all the resources in a Frontier instance.

<MimeTabs><TabItem label={"application/json"} value={"application/json-schema"}><details style={{}} data-collapsed={false} open={true}><summary style={{"textAlign":"left"}}><strong>Request Body</strong><strong style={{"fontSize":"var(--ifm-code-font-size)","color":"var(--openapi-required)"}}> required</strong></summary><div style={{"textAlign":"left","marginLeft":"1rem"}}></div><ul style={{"marginLeft":"1rem"}}><SchemaItem collapsible={true} className={"schemaItem"}><details style={{}}><summary style={{}}><strong>bodies</strong><span style={{"opacity":"0.6"}}> object[]</span></summary><div style={{"marginLeft":"1rem"}}><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem","paddingBottom":".5rem"}}>Array [</div></li><SchemaItem collapsible={false} name={"name"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The name of the permission. It should be unique across a Frontier instance and can contain only alphanumeric characters."}}></SchemaItem><SchemaItem collapsible={false} name={"namespace"} required={false} schemaName={"namespace should be in service/resource format"} qualifierMessage={undefined} schema={{"type":"string","description":"The namespace of the permission.The namespace should be in service/resource format.<br/>*Example:*`app/guardian`","title":"namespace should be in service/resource format"}}></SchemaItem><SchemaItem collapsible={false} name={"metadata"} required={false} schemaName={"object"} qualifierMessage={undefined} schema={{"type":"object","description":"The metadata object for permissions that can hold key value pairs."}}></SchemaItem><SchemaItem collapsible={false} name={"title"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The title can contain any UTF-8 character, used to provide a human-readable name for the permissions. Can also be left empty."}}></SchemaItem><SchemaItem collapsible={false} name={"key"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","example":"compute.instance.get","description":"Permission path key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'."}}></SchemaItem><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem"}}>]</div></li></div></details></SchemaItem></ul></details></TabItem></MimeTabs><div><ApiTabs><TabItem label={"200"} value={"200"}><div>
<MimeTabs><TabItem label={"application/json"} value={"application/json-schema"}><details style={{}} data-collapsed={false} open={true}><summary style={{"textAlign":"left"}}><strong>Request Body</strong><strong style={{"fontSize":"var(--ifm-code-font-size)","color":"var(--openapi-required)"}}> required</strong></summary><div style={{"textAlign":"left","marginLeft":"1rem"}}></div><ul style={{"marginLeft":"1rem"}}><SchemaItem collapsible={true} className={"schemaItem"}><details style={{}}><summary style={{}}><strong>bodies</strong><span style={{"opacity":"0.6"}}> object[]</span></summary><div style={{"marginLeft":"1rem"}}><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem","paddingBottom":".5rem"}}>Array [</div></li><SchemaItem collapsible={false} name={"name"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The name of the permission. It should be unique across a Frontier instance and can contain only alphanumeric characters."}}></SchemaItem><SchemaItem collapsible={false} name={"namespace"} required={false} schemaName={"namespace should be in service/resource format"} qualifierMessage={undefined} schema={{"type":"string","description":"The namespace of the permission. The namespace should be in service/resource format.<br/>*Example:*`compute/guardian`","title":"namespace should be in service/resource format"}}></SchemaItem><SchemaItem collapsible={false} name={"metadata"} required={false} schemaName={"object"} qualifierMessage={undefined} schema={{"type":"object","description":"The metadata object for permissions that can hold key value pairs."}}></SchemaItem><SchemaItem collapsible={false} name={"title"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The title can contain any UTF-8 character, used to provide a human-readable name for the permissions. Can also be left empty."}}></SchemaItem><SchemaItem collapsible={false} name={"key"} required={false} schemaName={"key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'.\nUse this instead of using name and namespace fields"} qualifierMessage={undefined} schema={{"type":"string","example":"compute.instance.get","description":"Permission path key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'. Namespace name cannot be `app` as it's reserved for core permissions.","title":"key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'.\nUse this instead of using name and namespace fields"}}></SchemaItem><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem"}}>]</div></li></div></details></SchemaItem></ul></details></TabItem></MimeTabs><div><ApiTabs><TabItem label={"200"} value={"200"}><div>

A successful response.

Expand Down
Loading
Loading