From 06ab97c56c92e9490567c64a0f1a3d58966de066 Mon Sep 17 00:00:00 2001 From: emmdim Date: Thu, 23 Jan 2025 14:54:24 +0100 Subject: [PATCH] * Implements subscription limitations * Separate limitations as DB and TX limitations * Adds handler for SET_ACCOUNT_INFO_URI transaction * Related to #39 --- api/organizations.go | 31 ++++++- api/transaction.go | 52 ++++++++++-- db/types.go | 26 ++++-- subscriptions/subscriptions.go | 151 +++++++++++++++++++++++++++++++-- 4 files changed, 237 insertions(+), 23 deletions(-) diff --git a/api/organizations.go b/api/organizations.go index 50181d0..f19b193 100644 --- a/api/organizations.go +++ b/api/organizations.go @@ -9,6 +9,7 @@ import ( "github.com/vocdoni/saas-backend/db" "github.com/vocdoni/saas-backend/internal" "github.com/vocdoni/saas-backend/notifications/mailtemplates" + "github.com/vocdoni/saas-backend/subscriptions" "go.vocdoni.io/dvote/log" ) @@ -43,6 +44,13 @@ func (a *API) createOrganizationHandler(w http.ResponseWriter, r *http.Request) parentOrg := "" var dbParentOrg *db.Organization if orgInfo.Parent != nil { + // check if the org has permission to create suborganizations + hasPermission, err := a.subscriptions.HasDBPersmission(user.Email, orgInfo.Parent.Address, subscriptions.CreateSubOrg) + if !hasPermission || err != nil { + ErrUnauthorized.Withf("user does not have permission to create suborganizations: %v", err).Write(w) + return + } + dbParentOrg, _, err = a.db.Organization(orgInfo.Parent.Address, false) if err != nil { if err == db.ErrNotFound { @@ -107,6 +115,15 @@ func (a *API) createOrganizationHandler(w http.ResponseWriter, r *http.Request) ErrGenericInternalServerError.Write(w) return } + + // update the parent organization counter + if orgInfo.Parent != nil { + dbParentOrg.Counters.SubOrgs++ + if err := a.db.SetOrganization(dbParentOrg); err != nil { + ErrGenericInternalServerError.Withf("could not update parent organization: %v", err).Write(w) + return + } + } // send the organization back to the user httpWriteJSON(w, organizationFromDB(dbOrg, dbParentOrg)) } @@ -260,8 +277,11 @@ func (a *API) inviteOrganizationMemberHandler(w http.ResponseWriter, r *http.Req ErrNoOrganizationProvided.Write(w) return } - if !user.HasRoleFor(org.Address, db.AdminRole) { - ErrUnauthorized.Withf("user is not admin of organization").Write(w) + + // check if the user/org has permission to invite members + hasPermission, err := a.subscriptions.HasDBPersmission(user.Email, org.Address, subscriptions.InviteMember) + if !hasPermission || err != nil { + ErrUnauthorized.Withf("user does not have permission to sign transactions: %v", err).Write(w) return } // get new admin info from the request body @@ -315,6 +335,13 @@ func (a *API) inviteOrganizationMemberHandler(w http.ResponseWriter, r *http.Req ErrGenericInternalServerError.Write(w) return } + + // update the org members counter + org.Counters.Members++ + if err := a.db.SetOrganization(org); err != nil { + ErrGenericInternalServerError.Withf("could not update organization: %v", err).Write(w) + return + } httpWriteOK(w) } diff --git a/api/transaction.go b/api/transaction.go index c5529d7..ea5f1c4 100644 --- a/api/transaction.go +++ b/api/transaction.go @@ -28,9 +28,9 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { ErrMalformedBody.Withf("could not decode request body: %v", err).Write(w) return } - // check if the user has the admin role for the organization - if !user.HasRoleFor(signReq.Address, db.AdminRole) { - ErrUnauthorized.With("user does not have admin role").Write(w) + // check if the user is a member of the organization + if !user.IsMemberOf(signReq.Address) { + ErrUnauthorized.With("user is not an organization member").Write(w) return } // get the organization info from the database with the address provided in @@ -67,6 +67,9 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { ErrInvalidTxFormat.Write(w) return } + // flag to know if the TX is New Process + isNewProcess := false + // check if the api is not in transparent mode if !a.transparentMode { // get subscription plan @@ -83,6 +86,10 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { ErrUnauthorized.With("invalid account").Write(w) return } + if hasPermission, err := a.subscriptions.HasTxPermission(tx, txSetAccount.Txtype, org, user); !hasPermission || err != nil { + ErrUnauthorized.Withf("user does not have permission to sign transactions: %v", err).Write(w) + return + } // check the tx subtype switch txSetAccount.Txtype { case models.TxType_CREATE_ACCOUNT: @@ -107,6 +114,29 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { }, } } + case models.TxType_SET_ACCOUNT_INFO_URI: + // generate a new faucet package if it's not present and include it in the tx + if txSetAccount.FaucetPackage == nil { + // get the tx cost for the tx type + amount, ok := a.account.TxCosts[models.TxType_SET_ACCOUNT_INFO_URI] + if !ok { + panic("invalid tx type") + } + // generate the faucet package with the calculated amount + faucetPkg, err := a.account.FaucetPackage(organizationSigner.AddressString(), amount) + if err != nil { + ErrCouldNotCreateFaucetPackage.WithErr(err).Write(w) + return + } + // include the faucet package in the tx + txSetAccount.FaucetPackage = faucetPkg + tx = &models.Tx{ + Payload: &models.Tx_SetAccount{ + SetAccount: txSetAccount, + }, + } + } + } case *models.Tx_NewProcess: txNewProcess := tx.GetNewProcess() @@ -116,14 +146,14 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { ErrInvalidTxFormat.With("missing fields").Write(w) return } - if hasPermission, err := a.subscriptions.HasPermission(tx, txNewProcess.Txtype, org); !hasPermission || err != nil { + if hasPermission, err := a.subscriptions.HasTxPermission(tx, txNewProcess.Txtype, org, user); !hasPermission || err != nil { ErrUnauthorized.Withf("user does not have permission to sign transactions: %v", err).Write(w) return } // check the tx subtype switch txNewProcess.Txtype { case models.TxType_NEW_PROCESS: - + isNewProcess = true // generate a new faucet package if it's not present and include it in the tx if txNewProcess.FaucetPackage == nil { // get the tx cost for the tx type @@ -169,7 +199,7 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { ErrInvalidTxFormat.With("invalid tx type").Write(w) return } - if hasPermission, err := a.subscriptions.HasPermission(tx, txSetProcess.Txtype, org); !hasPermission || err != nil { + if hasPermission, err := a.subscriptions.HasTxPermission(tx, txSetProcess.Txtype, org, user); !hasPermission || err != nil { ErrUnauthorized.Withf("user does not have permission to sign transactions: %v", err).Write(w) return } @@ -315,6 +345,16 @@ func (a *API) signTxHandler(w http.ResponseWriter, r *http.Request) { ErrCouldNotSignTransaction.WithErr(err).Write(w) return } + + // If isNewProcess and everything went well so far update the organization process counter + if isNewProcess { + org.Counters.Processes++ + if err := a.db.SetOrganization(org); err != nil { + ErrGenericInternalServerError.Withf("could not update organization process counter: %v", err).Write(w) + return + } + } + // return the signed tx payload httpWriteJSON(w, &TransactionData{ TxPayload: base64.StdEncoding.EncodeToString(stx), diff --git a/db/types.go b/db/types.go index 4a7c0ff..5e3e7c1 100644 --- a/db/types.go +++ b/db/types.go @@ -32,6 +32,15 @@ func (u *User) HasRoleFor(address string, role UserRole) bool { return false } +func (u *User) IsMemberOf(address string) bool { + for _, org := range u.Organizations { + if org.Address == address { + return true + } + } + return false +} + type UserRole string type OrganizationType string @@ -63,14 +72,14 @@ type Organization struct { } type PlanLimits struct { - Members int `json:"members" bson:"members"` - SubOrgs int `json:"subOrgs" bson:"subOrgs"` - CensusSize int `json:"censusSize" bson:"censusSize"` - MaxProcesses int `json:"maxProcesses" bson:"maxProcesses"` - MaxCensus int `json:"maxCensus" bson:"maxCensus"` - MaxDuration string `json:"maxDuration" bson:"maxDuration"` - CustomURL bool `json:"customURL" bson:"customURL"` - Drafts int `json:"drafts" bson:"drafts"` + Members int `json:"members" bson:"members"` + SubOrgs int `json:"subOrgs" bson:"subOrgs"` + MaxProcesses int `json:"maxProcesses" bson:"maxProcesses"` + MaxCensus int `json:"maxCensus" bson:"maxCensus"` + // Max process duration in days + MaxDuration string `json:"maxDuration" bson:"maxDuration"` + CustomURL bool `json:"customURL" bson:"customURL"` + Drafts int `json:"drafts" bson:"drafts"` } type VotingTypes struct { @@ -126,6 +135,7 @@ type OrganizationCounters struct { SentEmails int `json:"sentEmails" bson:"sentEmails"` SubOrgs int `json:"subOrgs" bson:"subOrgs"` Members int `json:"members" bson:"members"` + Processes int `json:"processes" bson:"processes"` } type OrganizationInvite struct { diff --git a/subscriptions/subscriptions.go b/subscriptions/subscriptions.go index 9efe6f5..86b8264 100644 --- a/subscriptions/subscriptions.go +++ b/subscriptions/subscriptions.go @@ -2,6 +2,7 @@ package subscriptions import ( "fmt" + "strconv" "github.com/vocdoni/saas-backend/db" "go.vocdoni.io/proto/build/go/models" @@ -13,6 +14,32 @@ type SubscriptionsConfig struct { DB *db.MongoStorage } +// OrgPermission represents the permissions that an organization can have. +type DBPermission int + +const ( + InviteMember DBPermission = iota + DeleteMember + CreateSubOrg + CreateDraft +) + +// String returns the string representation of the DBPermission. +func (p DBPermission) String() string { + switch p { + case InviteMember: + return "InviteMember" + case DeleteMember: + return "DeleteMember" + case CreateSubOrg: + return "CreateSubOrg" + case CreateDraft: + return "CreateDraft" + default: + return "Unknown" + } +} + // Subscriptions is the service that manages the organization permissions based on // the subscription plans. type Subscriptions struct { @@ -29,23 +56,133 @@ func New(conf *SubscriptionsConfig) *Subscriptions { } } -// HasPermission checks if the organization has permission to perform the given transaction. -func (p *Subscriptions) HasPermission( +// hasElectionMetadataPermissions checks if the organization has permission to create an election with the given metadata. +func (p *Subscriptions) hasElectionMetadataPermissions(process *models.NewProcessTx, plan *db.Plan) (bool, error) { + // check ANONYMOUS + if process.Process.EnvelopeType.Anonymous && !plan.Features.Anonymous { + return false, fmt.Errorf("anonymous elections are not allowed") + } + + // check WEIGHTED + if process.Process.EnvelopeType.CostFromWeight && !plan.VotingTypes.Weighted { + return false, fmt.Errorf("weighted elections are not allowed") + } + + // check VOTE OVERWRITE + if process.Process.VoteOptions.MaxVoteOverwrites > 0 && !plan.Features.Overwrite { + return false, fmt.Errorf("vote overwrites are not allowed") + } + + // check PROCESS DURATION + duration, err := daysDurationToSeconds(plan.Organization.MaxDuration) + if err != nil { + return false, fmt.Errorf("could not convert duration to seconds: %v", err) + } + if process.Process.Duration > duration { + return false, fmt.Errorf("duration is greater than the allowed") + } + + // TODO:future check if the election voting type is supported by the plan + // TODO:future check if the streamURL is used and allowed by the plan + + return true, nil +} + +// HasTxPermission checks if the organization has permission to perform the given transaction. +func (p *Subscriptions) HasTxPermission( tx *models.Tx, txType models.TxType, org *db.Organization, + user *db.User, ) (bool, error) { - // get subscription plan - // plan, err := p.db.Subscription(org.Subscription.SubscriptionID) - // if err != nil { - // return false, fmt.Errorf("could not get organization subscription: %v", err) - // } + plan, err := p.db.Plan(org.Subscription.PlanID) + if err != nil { + return false, fmt.Errorf("could not get organization plan: %v", err) + } + switch txType { + // check UPDATE ACCOUNT INFO + case models.TxType_SET_ACCOUNT_INFO_URI: + // check if the user has the admin role for the organization + if !user.HasRoleFor(org.Address, db.AdminRole) { + return false, fmt.Errorf("user does not have admin role") + } + + // check CREATE PROCESS case models.TxType_NEW_PROCESS, models.TxType_SET_PROCESS_CENSUS: + // check if the user has the admin role for the organization + if !user.HasRoleFor(org.Address, db.AdminRole) { + return false, fmt.Errorf("user does not have admin role") + } newProcess := tx.GetNewProcess() if newProcess.Process.MaxCensusSize > uint64(org.Subscription.MaxCensusSize) { return false, fmt.Errorf("MaxCensusSize is greater than the allowed") } + if org.Counters.Processes >= plan.Organization.MaxProcesses { + return false, fmt.Errorf("max processes reached") + } + return p.hasElectionMetadataPermissions(newProcess, plan) + + // check SET_PROCESS + case models.TxType_SET_PROCESS_STATUS: + // check if the user has the admin role for the organization + if !user.HasRoleFor(org.Address, db.AdminRole) || !user.HasRoleFor(org.Address, db.ManagerRole) { + return false, fmt.Errorf("user does not have admin role") + } + } + return true, nil } + +// HasDBPersmission checks if the user has permission to perform the given action in the organization stored in the DB +func (p *Subscriptions) HasDBPersmission(userEmail, orgAddress string, permission DBPermission) (bool, error) { + user, err := p.db.UserByEmail(userEmail) + if err != nil { + return false, fmt.Errorf("could not get user: %v", err) + } + org, _, err := p.db.Organization(orgAddress, false) + if err != nil { + return false, fmt.Errorf("could not get organization: %v", err) + } + plan, err := p.db.Plan(org.Subscription.PlanID) + if err != nil { + return false, fmt.Errorf("could not get organization plan: %v", err) + } + switch permission { + case InviteMember: + // check if the user has permission to invite members + if !user.HasRoleFor(orgAddress, db.AdminRole) { + return false, fmt.Errorf("user does not have admin role") + } + if org.Counters.Members >= plan.Organization.Members { + return false, fmt.Errorf("max members reached") + } + return true, nil + case DeleteMember: + // check if the user has permission to delete members + if !user.HasRoleFor(orgAddress, db.AdminRole) { + return false, fmt.Errorf("user does not have admin role") + } + return true, nil + case CreateSubOrg: + // check if the user has permission to create sub organizations + if !user.HasRoleFor(orgAddress, db.AdminRole) { + return false, fmt.Errorf("user does not have admin role") + } + if org.Counters.SubOrgs >= plan.Organization.SubOrgs { + return false, fmt.Errorf("max sub organizations reached") + } + return true, nil + } + return false, fmt.Errorf("permission not found") +} + +// In the plan the duration is given in a string +func daysDurationToSeconds(duration string) (uint32, error) { + num, err := strconv.ParseUint(duration, 10, 32) + if err != nil { + return 0, err + } + return uint32(num * 24 * 60 * 60), nil +}